@modusensus/dsh-mneme 0.7.10 → 0.7.12

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 (158) hide show
  1. package/README.en.md +437 -0
  2. package/README.md +364 -166
  3. package/bin/cli.mjs +603 -0
  4. package/{dsh-mneme/cordis.patch.yml → cordis.patch.yml} +1 -0
  5. package/lib/api-standalone.js +264 -0
  6. package/{dsh-mneme/lib → lib}/api.js +133 -289
  7. package/{dsh-mneme/lib → lib}/client.js +1008 -922
  8. package/{dsh-mneme/lib → lib}/config.js +54 -99
  9. package/{dsh-mneme/lib → lib}/dream/decisions.js +94 -143
  10. package/{dsh-mneme/lib → lib}/dream/sleep.js +12 -152
  11. package/{dsh-mneme/lib → lib}/dream.js +22 -168
  12. package/{dsh-mneme/src → lib}/hot-memory.js +0 -7
  13. package/{dsh-mneme/lib → lib}/index.js +45 -42
  14. package/{dsh-mneme/lib → lib}/inject.js +11 -53
  15. package/{dsh-mneme/src → lib}/mirror.js +1 -12
  16. package/{dsh-mneme/lib → lib}/quality-filter.js +1 -3
  17. package/{dsh-mneme/lib → lib}/reranker.js +1 -4
  18. package/{dsh-mneme/lib → lib}/service.js +20 -410
  19. package/{dsh-mneme/src → lib}/settings.js +34 -64
  20. package/{dsh-mneme/lib → lib}/store.js +38 -450
  21. package/{dsh-mneme/lib → lib}/summarize.js +10 -10
  22. package/lib/tools.js +274 -0
  23. package/package.json +45 -19
  24. package/{dsh-mneme/scripts → scripts}/e2e-dsh.js +3 -4
  25. package/{dsh-mneme/scripts → scripts}/sync-lib.js +5 -10
  26. package/src/api-standalone.js +264 -0
  27. package/{dsh-mneme/src → src}/api.js +133 -289
  28. package/{dsh-mneme/src → src}/config.js +54 -99
  29. package/{dsh-mneme/src → src}/dream/decisions.js +94 -143
  30. package/{dsh-mneme/src → src}/dream/sleep.js +12 -152
  31. package/{dsh-mneme/src → src}/dream.js +22 -168
  32. package/{dsh-mneme/lib → src}/hot-memory.js +0 -7
  33. package/{dsh-mneme/src → src}/index.js +45 -42
  34. package/{dsh-mneme/src → src}/inject.js +11 -53
  35. package/{dsh-mneme/lib → src}/mirror.js +1 -12
  36. package/{dsh-mneme/src → src}/quality-filter.js +1 -3
  37. package/{dsh-mneme/src → src}/reranker.js +1 -4
  38. package/{dsh-mneme/src → src}/service.js +20 -410
  39. package/{dsh-mneme/lib → src}/settings.js +34 -64
  40. package/{dsh-mneme/src → src}/store.js +38 -450
  41. package/{dsh-mneme/src → src}/summarize.js +10 -10
  42. package/src/tools.js +274 -0
  43. package/{dsh-mneme/test → test}/api.test.js +45 -156
  44. package/{dsh-mneme/test → test}/client.test.js +10 -206
  45. package/{dsh-mneme/test → test}/config.test.js +0 -21
  46. package/{dsh-mneme/test → test}/dream.test.js +1 -160
  47. package/{dsh-mneme/test → test}/graph-api.test.js +0 -34
  48. package/{dsh-mneme/test → test}/hot-memory.test.js +0 -29
  49. package/test/inject.test.js +120 -0
  50. package/{dsh-mneme/test → test}/llm-audit.test.js +4 -4
  51. package/{dsh-mneme/test → test}/reasoning-effort.test.js +0 -27
  52. package/{dsh-mneme/test → test}/recall-layer.test.js +5 -26
  53. package/{dsh-mneme/test → test}/reranker.test.js +0 -43
  54. package/{dsh-mneme/test → test}/service-search.test.js +0 -25
  55. package/{dsh-mneme/test → test}/service.test.js +0 -106
  56. package/{dsh-mneme/test → test}/settings.test.js +16 -35
  57. package/{dsh-mneme/test → test}/sleep.test.js +4 -171
  58. package/test/standalone-api.test.js +326 -0
  59. package/{dsh-mneme/test → test}/store.test.js +0 -76
  60. package/{dsh-mneme/test → test}/summarize.test.js +16 -15
  61. package/test/tools.test.js +265 -0
  62. package/.github/workflows/publish.yml +0 -58
  63. package/.github/workflows/test.yml +0 -32
  64. package/CHANGELOG.md +0 -91
  65. package/CONTRIBUTING.md +0 -293
  66. package/SECURITY.md +0 -718
  67. package/docs/devlog/2026-08-14-dsh-mneme-dev-log.md +0 -247
  68. package/docs/devlog/2026-08-15-dsh-mneme-audit-stress-dev-log.md +0 -145
  69. package/docs/devlog/2026-08-15-dsh-mneme-pipeline-dev-log.md +0 -56
  70. package/docs/devlog/2026-08-15-dsh-mneme-reflection-dev-log.md +0 -77
  71. package/docs/devlog/2026-08-15-dsh-mneme-review-fixes-dev-log.md +0 -64
  72. package/docs/devlog/2026-08-15-dsh-mneme-semantic-dev-log.md +0 -90
  73. package/dsh-mneme/CHANGELOG.md +0 -419
  74. package/dsh-mneme/LICENSE +0 -21
  75. package/dsh-mneme/README.md +0 -501
  76. package/dsh-mneme/docs/AGENT_MEMORY_RESEARCH.md +0 -183
  77. package/dsh-mneme/docs/ENTITIES.md +0 -245
  78. package/dsh-mneme/docs/LOCAL_MODEL.md +0 -141
  79. package/dsh-mneme/docs/MIGRATION.md +0 -127
  80. package/dsh-mneme/docs/SEMANTIC.md +0 -256
  81. package/dsh-mneme/docs/SLEEP.md +0 -163
  82. package/dsh-mneme/lib/dream/tag-extractor.js +0 -156
  83. package/dsh-mneme/lib/heat.js +0 -136
  84. package/dsh-mneme/lib/parser/tag.js +0 -59
  85. package/dsh-mneme/lib/parser/wiki-link.js +0 -38
  86. package/dsh-mneme/lib/search/tag-boost.js +0 -61
  87. package/dsh-mneme/lib/tools.js +0 -458
  88. package/dsh-mneme/package-lock.json +0 -1936
  89. package/dsh-mneme/package.json +0 -83
  90. package/dsh-mneme/scripts/check-sync.js +0 -42
  91. package/dsh-mneme/src/client.js +0 -2242
  92. package/dsh-mneme/src/dream/tag-extractor.js +0 -156
  93. package/dsh-mneme/src/heat.js +0 -136
  94. package/dsh-mneme/src/parser/tag.js +0 -59
  95. package/dsh-mneme/src/parser/wiki-link.js +0 -38
  96. package/dsh-mneme/src/search/tag-boost.js +0 -61
  97. package/dsh-mneme/src/tools.js +0 -458
  98. package/dsh-mneme/test/boundary-v0625.test.js +0 -82
  99. package/dsh-mneme/test/directory.test.js +0 -134
  100. package/dsh-mneme/test/heat.test.js +0 -148
  101. package/dsh-mneme/test/inject.test.js +0 -206
  102. package/dsh-mneme/test/layered-types-stats.test.js +0 -144
  103. package/dsh-mneme/test/lib-smoke.test.js +0 -109
  104. package/dsh-mneme/test/normalize-decisions.test.js +0 -120
  105. package/dsh-mneme/test/provenance.test.js +0 -103
  106. package/dsh-mneme/test/recall-runs.test.js +0 -93
  107. package/dsh-mneme/test/sleep-heat.test.js +0 -112
  108. package/dsh-mneme/test/tag-boost.test.js +0 -125
  109. package/dsh-mneme/test/tag.test.js +0 -426
  110. package/dsh-mneme/test/tools.test.js +0 -674
  111. package/dsh-mneme/test/updated-at-semantics.test.js +0 -113
  112. package/dsh-mneme/test/wiki-link.test.js +0 -332
  113. package//346/250/252/345/271/205.png +0 -0
  114. /package/{dsh-mneme/lib → lib}/commands.js +0 -0
  115. /package/{dsh-mneme/lib → lib}/dream/clustering.js +0 -0
  116. /package/{dsh-mneme/lib → lib}/embedding.js +0 -0
  117. /package/{dsh-mneme/lib → lib}/entities/extractor.js +0 -0
  118. /package/{dsh-mneme/lib → lib}/local-embedder.js +0 -0
  119. /package/{dsh-mneme/lib → lib}/search/adaptive.js +0 -0
  120. /package/{dsh-mneme/lib → lib}/search/bm25.js +0 -0
  121. /package/{dsh-mneme/lib → lib}/vector-index.js +0 -0
  122. /package/{dsh-mneme/scripts → scripts}/benchmark-embed.js +0 -0
  123. /package/{dsh-mneme/scripts → scripts}/benchmark-recall.js +0 -0
  124. /package/{dsh-mneme/scripts → scripts}/benchmark-rerank.js +0 -0
  125. /package/{dsh-mneme/scripts → scripts}/stress-dsh.js +0 -0
  126. /package/{dsh-mneme/src → src}/commands.js +0 -0
  127. /package/{dsh-mneme/src → src}/dream/clustering.js +0 -0
  128. /package/{dsh-mneme/src → src}/embedding.js +0 -0
  129. /package/{dsh-mneme/src → src}/entities/extractor.js +0 -0
  130. /package/{dsh-mneme/src → src}/local-embedder.js +0 -0
  131. /package/{dsh-mneme/src → src}/search/adaptive.js +0 -0
  132. /package/{dsh-mneme/src → src}/search/bm25.js +0 -0
  133. /package/{dsh-mneme/src → src}/vector-index.js +0 -0
  134. /package/{dsh-mneme/test → test}/audit.test.js +0 -0
  135. /package/{dsh-mneme/test → test}/benchmark.test.js +0 -0
  136. /package/{dsh-mneme/test → test}/clustering.test.js +0 -0
  137. /package/{dsh-mneme/test → test}/commands.test.js +0 -0
  138. /package/{dsh-mneme/test → test}/conflict-freeze.test.js +0 -0
  139. /package/{dsh-mneme/test → test}/entities.test.js +0 -0
  140. /package/{dsh-mneme/test → test}/epistemic.test.js +0 -0
  141. /package/{dsh-mneme/test → test}/fnew-0112.test.js +0 -0
  142. /package/{dsh-mneme/test → test}/fnew-03.test.js +0 -0
  143. /package/{dsh-mneme/test → test}/helpers/dream-mock.js +0 -0
  144. /package/{dsh-mneme/test → test}/local-embedder.test.js +0 -0
  145. /package/{dsh-mneme/test → test}/mirror-dirty.test.js +0 -0
  146. /package/{dsh-mneme/test → test}/mirror-edit-digest.test.js +0 -0
  147. /package/{dsh-mneme/test → test}/mirror-generation.test.js +0 -0
  148. /package/{dsh-mneme/test → test}/mirror.test.js +0 -0
  149. /package/{dsh-mneme/test → test}/peer-blockers.test.js +0 -0
  150. /package/{dsh-mneme/test → test}/policy-epoch.test.js +0 -0
  151. /package/{dsh-mneme/test → test}/quality-filter.test.js +0 -0
  152. /package/{dsh-mneme/test → test}/recall-evals.test.js +0 -0
  153. /package/{dsh-mneme/test → test}/receipt-chain.test.js +0 -0
  154. /package/{dsh-mneme/test → test}/reflection.test.js +0 -0
  155. /package/{dsh-mneme/test → test}/search-fusion.test.js +0 -0
  156. /package/{dsh-mneme/test → test}/semantic.test.js +0 -0
  157. /package/{dsh-mneme/test → test}/stress.test.js +0 -0
  158. /package/{dsh-mneme/test → test}/vector-index.test.js +0 -0
@@ -39,6 +39,41 @@ window.__ModuleLoader__.load({
39
39
  h("circle", { cx: 12.4, cy: 12, r: 1.8, fill: "currentColor" })
40
40
  );
41
41
 
42
+ // Stroke icons, Lucide path data (ISC license) inlined as [tag, attrs]
43
+ // tuples — the plugin runtime cannot require third-party libraries, so
44
+ // the data ships with the bundle (the morphicons/lucide pairing the
45
+ // data package documents, minus the runtime dependency). Stroke picks
46
+ // up currentColor, so icons follow the host theme tokens.
47
+ const ICON_PATHS = {
48
+ database: [["ellipse", { cx: "12", cy: "5", rx: "9", ry: "3" }], ["path", { d: "M3 5V19A9 3 0 0 0 21 19V5" }], ["path", { d: "M3 12A9 3 0 0 0 21 12" }]],
49
+ waypoints: [["path", { d: "m10.586 5.414-5.172 5.172" }], ["path", { d: "m18.586 13.414-5.172 5.172" }], ["path", { d: "M6 12h12" }], ["circle", { cx: "12", cy: "20", r: "2" }], ["circle", { cx: "12", cy: "4", r: "2" }], ["circle", { cx: "20", cy: "12", r: "2" }], ["circle", { cx: "4", cy: "12", r: "2" }]],
50
+ settings: [["path", { d: "M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" }], ["circle", { cx: "12", cy: "12", r: "3" }]],
51
+ search: [["path", { d: "m21 21-4.34-4.34" }], ["circle", { cx: "11", cy: "11", r: "8" }]],
52
+ refresh: [["path", { d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" }], ["path", { d: "M21 3v5h-5" }], ["path", { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" }], ["path", { d: "M8 16H3v5" }]],
53
+ chevronDown: [["path", { d: "m6 9 6 6 6-6" }]],
54
+ chevronRight: [["path", { d: "m9 18 6-6-6-6" }]],
55
+ copy: [["rect", { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" }], ["path", { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" }]],
56
+ check: [["path", { d: "M20 6 9 17l-5-5" }]],
57
+ inbox: [["polyline", { points: "22 12 16 12 14 15 10 15 8 12 2 12" }], ["path", { d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" }]],
58
+ activity: [["path", { d: "M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" }]]
59
+ };
60
+ const Icon = ({ name, size = 16, className }) => {
61
+ const parts = ICON_PATHS[name];
62
+ if (!parts) return null;
63
+ return h("svg", {
64
+ width: size,
65
+ height: size,
66
+ viewBox: "0 0 24 24",
67
+ fill: "none",
68
+ stroke: "currentColor",
69
+ strokeWidth: 2,
70
+ strokeLinecap: "round",
71
+ strokeLinejoin: "round",
72
+ className,
73
+ "aria-hidden": "true"
74
+ }, parts.map(([tag, attrs], i) => h(tag, { key: i, ...attrs })));
75
+ };
76
+
42
77
  // Unified API fetcher: attaches the optional apiToken (set in the settings
43
78
  // view, persisted in localStorage) as a Bearer header. When no token has
44
79
  // been configured the header is omitted and the API stays open (default).
@@ -65,8 +100,6 @@ window.__ModuleLoader__.load({
65
100
  "memory.tab.project": "项目",
66
101
  "memory.tab.decision": "决策",
67
102
  "memory.tab.history": "历史",
68
- "memory.tab.user": "用户",
69
- "memory.tab.fact": "事实",
70
103
  "memory.settings.title": "记忆库设置",
71
104
  "memory.settings.profile": "用户画像",
72
105
  "memory.settings.profileHint": "写一段自我介绍(角色、背景、偏好),Agent 每轮对话都会自动带上。",
@@ -74,16 +107,6 @@ window.__ModuleLoader__.load({
74
107
  "memory.settings.profileSaved": "画像已保存",
75
108
  "memory.settings.rules": "规则",
76
109
  "memory.settings.rulesHint": "给 Agent 立下必须遵守的规矩,随时增删,下一轮即生效。",
77
- "memory.settings.autoTagTitle": "自动打标签",
78
- "memory.settings.autoTagHint": "开启后,新记忆由轻量模型自动打标签,目录页按标签自动分类(opt-in,默认关)",
79
- "memory.settings.autoTagEnabled": "自动打标签",
80
- "memory.settings.autoTagSave": "保存",
81
- "memory.settings.autoTagSaved": "已保存",
82
- "memory.settings.sidebarTriggerTitle": "侧边栏入口按钮",
83
- "memory.settings.sidebarTriggerHint": "关闭后隐藏侧边栏底部(左下角)的记忆入口按钮。与其他插件 UI 冲突时建议关闭;记忆库仍可通过顶部「记忆库」标签访问",
84
- "memory.settings.sidebarTriggerEnabled": "显示左下角入口按钮",
85
- "memory.settings.sidebarTriggerSave": "保存",
86
- "memory.settings.sidebarTriggerSaved": "已保存",
87
110
  "memory.settings.ruleAdd": "添加规则",
88
111
  "memory.settings.rulePlaceholder": "例如:回答时总是先给结论",
89
112
  "memory.settings.commands": "自定义指令",
@@ -99,10 +122,23 @@ window.__ModuleLoader__.load({
99
122
  "memory.view.label": "记忆库",
100
123
  "memory.overlay.close": "关闭",
101
124
  "memory.explorer.tabMemory": "记忆",
102
- "memory.explorer.tabGraph": "图谱",
103
- "memory.explorer.tabDirectory": "目录",
125
+ "memory.explorer.tabEntities": "实体",
104
126
  "memory.explorer.tabSettings": "设置",
105
- "memory.explorer.tabOverview": "总览",
127
+ "memory.explorer.tabStatus": "状态",
128
+ "memory.entities.count": "{n} 个实体",
129
+ "memory.entities.pick": "从左侧选择一个实体,查看它的属性与关系",
130
+ "memory.entities.none": "暂无实体:开启实体抽取后,记忆里的人物 / 项目 / 概念会自动沉淀到这里",
131
+ "memory.entities.attrs": "属性",
132
+ "memory.entities.relations": "关系",
133
+ "memory.entities.mentions": "{n} 次提及",
134
+ "memory.entities.lastSeen": "最近出现",
135
+ "memory.entities.graph": "关系图谱",
136
+ "memory.entity.person": "人物",
137
+ "memory.entity.project": "项目",
138
+ "memory.entity.concept": "概念",
139
+ "memory.entity.technology": "技术",
140
+ "memory.entity.organization": "组织",
141
+ "memory.entity.other": "其他",
106
142
  "memory.explorer.search": "搜索标题或内容…",
107
143
  "memory.explorer.searchTitle": "语义检索",
108
144
  "memory.explorer.types": "分类",
@@ -112,34 +148,16 @@ window.__ModuleLoader__.load({
112
148
  "memory.explorer.copy": "复制全文",
113
149
  "memory.explorer.copied": "已复制",
114
150
  "memory.explorer.refresh": "刷新",
151
+ "memory.explorer.loadMore": "加载更多",
115
152
  "memory.explorer.count": "共 {n} 条",
116
153
  "memory.explorer.empty": "暂无记忆条目",
117
154
  "memory.explorer.source": "来源",
118
155
  "memory.explorer.created": "创建",
119
156
  "memory.explorer.updated": "更新",
120
157
  "memory.explorer.tags": "标签",
121
- "memory.explorer.tagAdd": "添加标签",
122
- "memory.explorer.tagRemove": "移除标签",
123
- "memory.explorer.tagPlaceholder": "输入标签,回车添加",
124
- "memory.explorer.tagsEmpty": "暂无标签",
125
158
  "memory.explorer.importance": "重要性",
126
159
  "memory.explorer.topK": "返回数量",
127
160
  "memory.explorer.topKOption": "返回 {n} 条",
128
- "memory.overview.empty": "还没有记忆。开始对话后 Agent 会自动沉淀记忆,这里会展示分层总览",
129
- "memory.overview.profileEmpty": "暂无用户画像记忆,可在对话中让 AI 记录",
130
- "memory.overview.distribution": "类型分布",
131
- "memory.overview.layers": "记忆分层",
132
- "memory.overview.trend": "近 7 天趋势",
133
- "memory.overview.none": "暂无",
134
- "memory.overview.badgeInject": "常注入",
135
- "memory.overview.badgeImportance": "按重要性",
136
- "memory.directory.untagged": "无标签",
137
- "memory.directory.loading": "加载中…",
138
- "memory.directory.empty": "暂无记忆条目",
139
- "directory.editToggle": "编辑模式(显示删除)",
140
- "directory.deleteConfirm": "确定删除这条记忆吗?",
141
- "directory.deleteConfirmShort": "确认?",
142
- "directory.deleteError": "删除失败",
143
161
  "memory.card.open": "在主区记忆库中查看全文",
144
162
  "memory.time.now": "刚刚",
145
163
  "memory.time.seconds": "{n}秒前",
@@ -158,7 +176,7 @@ window.__ModuleLoader__.load({
158
176
  "memory.graph.sourceMemory": "查看来源记忆",
159
177
  "memory.graph.hint": "拖拽节点调整布局 · 空白处拖动平移 · 滚轮缩放",
160
178
  "memory.graph.resetView": "重置视图",
161
- "memory.graph.viewInGraph": "在图谱中查看",
179
+ "memory.graph.viewInGraph": "在实体视图中打开",
162
180
  "memory.graph.loading": "加载中…",
163
181
  "memory.graph.distance": "距中心 {n} 跳",
164
182
  "memory.settings.vectorTitle": "向量搜索",
@@ -177,11 +195,36 @@ window.__ModuleLoader__.load({
177
195
  "memory.settings.apiTokenPlaceholder": "留空 = 不鉴权(默认)",
178
196
  "memory.settings.apiTokenSave": "保存 Token",
179
197
  "memory.settings.apiTokenSaved": "Token 已保存",
180
- "memory.wikilink.backlinks": "链接到此记忆",
181
- "memory.wikilink.forward": "此记忆链接到",
182
- "memory.wikilink.empty": "暂无关联记忆",
183
- "memory.wikilink.loading": "加载中…",
184
- "memory.wikilink.unresolved": "目标记忆不存在"
198
+ "memory.settings.mode.title": "运行模式",
199
+ "memory.settings.mode.desc": "轻量模式只保留核心的记忆读写与自动注入(关闭 autoDream 巩固、实体抽取、语义搜索等高级功能),适合只想「记住偏好」的轻量使用;标准模式开启全部功能。",
200
+ "memory.settings.mode.light": "轻量",
201
+ "memory.settings.mode.standard": "标准",
202
+ "memory.settings.mode.savedHint": "已保存,重启 DSH 后生效",
203
+ "memory.settings.mode.offList": "已关闭:巩固(autoDream)· 实体抽取 · 语义搜索",
204
+ "memory.settings.extapi.title": "外部访问 API",
205
+ "memory.settings.extapi.desc": "独立 HTTP 服务,供其他插件 / CLI / 桌面工具读写记忆,默认绑定 127.0.0.1。重启 DSH 后生效。",
206
+ "memory.settings.extapi.enabled": "启用",
207
+ "memory.settings.extapi.disabled": "停用",
208
+ "memory.settings.extapi.address": "地址",
209
+ "memory.settings.extapi.port": "端口",
210
+ "memory.settings.extapi.token": "Token",
211
+ "memory.settings.extapi.copy": "复制",
212
+ "memory.settings.extapi.copied": "已复制",
213
+ "memory.settings.extapi.savedHint": "已保存,重启 DSH 后生效",
214
+ "memory.settings.extapi.invalidPort": "端口需为 1-65535 的数字",
215
+ "memory.explorer.delete": "删除",
216
+ "memory.explorer.confirmDelete": "确认删除?",
217
+ "memory.explorer.cancel": "取消",
218
+ "memory.explorer.deleted": "已删除",
219
+ "memory.explorer.deleteFailed": "删除失败",
220
+ "memory.status.memories": "记忆总数",
221
+ "memory.status.entities": "实体",
222
+ "memory.status.vector": "向量索引",
223
+ "memory.status.vectorOn": "已启用",
224
+ "memory.status.vectorOff": "未启用",
225
+ "memory.status.llm": "LLM 消耗",
226
+ "memory.status.llmCalls": "近 7 天 · {n} 次调用",
227
+ "memory.status.error": "加载失败"
185
228
  },
186
229
  en: {
187
230
  "memory.panel.empty": "No memories yet",
@@ -191,8 +234,6 @@ window.__ModuleLoader__.load({
191
234
  "memory.tab.project": "Projects",
192
235
  "memory.tab.decision": "Decisions",
193
236
  "memory.tab.history": "History",
194
- "memory.tab.user": "User",
195
- "memory.tab.fact": "Facts",
196
237
  "memory.settings.title": "Memory Settings",
197
238
  "memory.settings.profile": "User Profile",
198
239
  "memory.settings.profileHint": "Describe yourself once — the agent reads it every turn.",
@@ -200,16 +241,6 @@ window.__ModuleLoader__.load({
200
241
  "memory.settings.profileSaved": "Profile saved",
201
242
  "memory.settings.rules": "Rules",
202
243
  "memory.settings.rulesHint": "Rules the agent must follow — add or remove anytime, effective next turn.",
203
- "memory.settings.autoTagTitle": "Auto-Tag",
204
- "memory.settings.autoTagHint": "When on, new memories are auto-tagged by a light model and the directory auto-grouped by tag (opt-in, off by default)",
205
- "memory.settings.autoTagEnabled": "Auto-tagging",
206
- "memory.settings.autoTagSave": "Save",
207
- "memory.settings.autoTagSaved": "Saved",
208
- "memory.settings.sidebarTriggerTitle": "Sidebar trigger",
209
- "memory.settings.sidebarTriggerHint": "Hide the memory entrance button at the sidebar footer (bottom-left) when it clashes with other plugins. The library stays reachable via the 记忆库 conversation tab",
210
- "memory.settings.sidebarTriggerEnabled": "Show the bottom-left trigger",
211
- "memory.settings.sidebarTriggerSave": "Save",
212
- "memory.settings.sidebarTriggerSaved": "Saved",
213
244
  "memory.settings.ruleAdd": "Add Rule",
214
245
  "memory.settings.rulePlaceholder": "e.g. Always lead with a conclusion",
215
246
  "memory.settings.commands": "Custom Commands",
@@ -225,10 +256,23 @@ window.__ModuleLoader__.load({
225
256
  "memory.view.label": "Memory",
226
257
  "memory.overlay.close": "Close",
227
258
  "memory.explorer.tabMemory": "Memories",
228
- "memory.explorer.tabGraph": "Graph",
229
- "memory.explorer.tabDirectory": "Directory",
259
+ "memory.explorer.tabEntities": "Entities",
230
260
  "memory.explorer.tabSettings": "Settings",
231
- "memory.explorer.tabOverview": "Overview",
261
+ "memory.explorer.tabStatus": "Status",
262
+ "memory.entities.count": "{n} entities",
263
+ "memory.entities.pick": "Select an entity to see its attributes and relations",
264
+ "memory.entities.none": "No entities yet — turn on entity extraction and people / projects / concepts will accumulate here",
265
+ "memory.entities.attrs": "Attributes",
266
+ "memory.entities.relations": "Relations",
267
+ "memory.entities.mentions": "{n} mentions",
268
+ "memory.entities.lastSeen": "Last seen",
269
+ "memory.entities.graph": "Relation graph",
270
+ "memory.entity.person": "People",
271
+ "memory.entity.project": "Projects",
272
+ "memory.entity.concept": "Concepts",
273
+ "memory.entity.technology": "Technologies",
274
+ "memory.entity.organization": "Organizations",
275
+ "memory.entity.other": "Others",
232
276
  "memory.explorer.search": "Search title or content…",
233
277
  "memory.explorer.searchTitle": "Semantic Search",
234
278
  "memory.explorer.types": "Types",
@@ -238,34 +282,16 @@ window.__ModuleLoader__.load({
238
282
  "memory.explorer.copy": "Copy content",
239
283
  "memory.explorer.copied": "Copied",
240
284
  "memory.explorer.refresh": "Refresh",
285
+ "memory.explorer.loadMore": "Load more",
241
286
  "memory.explorer.count": "{n} items",
242
287
  "memory.explorer.empty": "No memories yet",
243
288
  "memory.explorer.source": "Source",
244
289
  "memory.explorer.created": "Created",
245
290
  "memory.explorer.updated": "Updated",
246
291
  "memory.explorer.tags": "Tags",
247
- "memory.explorer.tagAdd": "Add tag",
248
- "memory.explorer.tagRemove": "Remove tag",
249
- "memory.explorer.tagPlaceholder": "Type a tag, press Enter",
250
- "memory.explorer.tagsEmpty": "No tags",
251
292
  "memory.explorer.importance": "Importance",
252
293
  "memory.explorer.topK": "Results limit",
253
294
  "memory.explorer.topKOption": "Return {n}",
254
- "memory.overview.empty": "No memories yet. Once you chat, the agent starts saving memories and the layered overview appears here",
255
- "memory.overview.profileEmpty": "No user-profile memories yet — ask the agent to remember things about you in chat",
256
- "memory.overview.distribution": "By Type",
257
- "memory.overview.layers": "Memory Layers",
258
- "memory.overview.trend": "Last 7 days",
259
- "memory.overview.none": "None",
260
- "memory.overview.badgeInject": "Auto-inject",
261
- "memory.overview.badgeImportance": "By importance",
262
- "memory.directory.untagged": "Untagged",
263
- "memory.directory.loading": "Loading…",
264
- "memory.directory.empty": "No memories yet",
265
- "directory.editToggle": "Edit mode",
266
- "directory.deleteConfirm": "Delete this memory?",
267
- "directory.deleteConfirmShort": "Confirm?",
268
- "directory.deleteError": "Delete failed",
269
295
  "memory.card.open": "Open full text in the Memory tab",
270
296
  "memory.time.now": "just now",
271
297
  "memory.time.seconds": "{n}s ago",
@@ -284,7 +310,7 @@ window.__ModuleLoader__.load({
284
310
  "memory.graph.sourceMemory": "View source memory",
285
311
  "memory.graph.hint": "Drag nodes to rearrange · drag the background to pan · scroll to zoom",
286
312
  "memory.graph.resetView": "Reset view",
287
- "memory.graph.viewInGraph": "View in graph",
313
+ "memory.graph.viewInGraph": "Open in entity explorer",
288
314
  "memory.graph.loading": "Loading…",
289
315
  "memory.graph.distance": "{n} hop(s) from root",
290
316
  "memory.settings.vectorTitle": "Vector Search",
@@ -303,11 +329,36 @@ window.__ModuleLoader__.load({
303
329
  "memory.settings.apiTokenPlaceholder": "Empty = no auth (default)",
304
330
  "memory.settings.apiTokenSave": "Save Token",
305
331
  "memory.settings.apiTokenSaved": "Token saved",
306
- "memory.wikilink.backlinks": "Links to this memory",
307
- "memory.wikilink.forward": "This memory links to",
308
- "memory.wikilink.empty": "No linked memories",
309
- "memory.wikilink.loading": "Loading…",
310
- "memory.wikilink.unresolved": "Target memory not found"
332
+ "memory.settings.mode.title": "Runtime mode",
333
+ "memory.settings.mode.desc": "Light mode keeps only the core memory read/write and auto-injection (autoDream consolidation, entity extraction and semantic search are off) — for light use where you just want preferences remembered. Standard mode enables everything.",
334
+ "memory.settings.mode.light": "Light",
335
+ "memory.settings.mode.standard": "Standard",
336
+ "memory.settings.mode.savedHint": "Saved. Takes effect after restarting DSH",
337
+ "memory.settings.mode.offList": "Off: consolidation (autoDream) · entity extraction · semantic search",
338
+ "memory.settings.extapi.title": "External API",
339
+ "memory.settings.extapi.desc": "A standalone HTTP service for other plugins / CLIs / desktop tools to read and write memories, bound to 127.0.0.1 by default. Takes effect after restarting DSH.",
340
+ "memory.settings.extapi.enabled": "Enable",
341
+ "memory.settings.extapi.disabled": "Disable",
342
+ "memory.settings.extapi.address": "Address",
343
+ "memory.settings.extapi.port": "Port",
344
+ "memory.settings.extapi.token": "Token",
345
+ "memory.settings.extapi.copy": "Copy",
346
+ "memory.settings.extapi.copied": "Copied",
347
+ "memory.settings.extapi.savedHint": "Saved. Takes effect after restarting DSH",
348
+ "memory.settings.extapi.invalidPort": "Port must be a number between 1 and 65535",
349
+ "memory.explorer.delete": "Delete",
350
+ "memory.explorer.confirmDelete": "Confirm delete?",
351
+ "memory.explorer.cancel": "Cancel",
352
+ "memory.explorer.deleted": "Deleted",
353
+ "memory.explorer.deleteFailed": "Delete failed",
354
+ "memory.status.memories": "Total memories",
355
+ "memory.status.entities": "Entities",
356
+ "memory.status.vector": "Vector index",
357
+ "memory.status.vectorOn": "Enabled",
358
+ "memory.status.vectorOff": "Disabled",
359
+ "memory.status.llm": "LLM Usage",
360
+ "memory.status.llmCalls": "Last 7 days · {n} calls",
361
+ "memory.status.error": "Failed to load"
311
362
  }
312
363
  };
313
364
 
@@ -375,17 +426,6 @@ window.__ModuleLoader__.load({
375
426
  ".mneme-hint{color:var(--dsw-alias-label-tertiary);padding:24px 0;text-align:center;font-size:13px}",
376
427
  ".mneme-entitychip{flex:none;height:26px;padding:0 10px;border-radius:8px;border:none;background:none;color:var(--dsw-alias-state-business-primary);cursor:pointer;font-family:inherit;font-size:12px;line-height:16px;display:inline-flex;align-items:center}",
377
428
  ".mneme-entitychip:hover{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent)}",
378
- // --- tag chips in the detail meta (v0.6.2) ---
379
- ".mneme-tagrow{display:flex;flex-wrap:wrap;gap:6px;align-items:center}",
380
- ".mneme-tagchip{display:inline-flex;align-items:center;gap:4px;max-width:100%;height:22px;padding:0 4px 0 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base,transparent);color:var(--dsw-alias-state-business-primary);cursor:pointer;font-family:inherit;font-size:12px;line-height:16px}",
381
- ".mneme-tagchip:hover{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent)}",
382
- ".mneme-tagremove{flex:none;display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border:none;border-radius:4px;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-family:inherit;font-size:12px;line-height:1;padding:0}",
383
- ".mneme-tagremove:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}",
384
- ".mneme-tagadd{border:none;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-family:inherit;font-size:12px;line-height:16px;padding:2px 8px;border-radius:6px;flex:none}",
385
- ".mneme-tagadd:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
386
- ".mneme-taginput{box-sizing:border-box;height:22px;padding:0 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base,transparent);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:12px;outline:none;width:150px}",
387
- ".mneme-taginput:focus{border-color:var(--dsw-alias-state-business-primary)}",
388
- ".mneme-tagempty{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px}",
389
429
  // --- main-area memory library page ---
390
430
  ".mneme-x{flex:1;min-height:0;height:100%;width:100%;box-sizing:border-box;display:flex;flex-direction:column;background:var(--dsw-alias-bg-layer-1);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2)}",
391
431
  ".mneme-xbar{flex:none;display:flex;align-items:center;gap:6px;border-bottom:1px solid var(--dsw-alias-border-l2);padding:0 16px}",
@@ -397,7 +437,11 @@ window.__ModuleLoader__.load({
397
437
  ".mneme-vtab.mneme-active::after{content:\"\";position:absolute;left:8px;right:8px;bottom:-1px;height:2px;border-radius:2px;background:var(--dsw-alias-state-business-primary)}",
398
438
  ".mneme-xtools{margin-left:auto;display:flex;align-items:center;gap:8px;padding:0 0 0 12px}",
399
439
  ".mneme-xcount{flex:none;font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}",
400
- // --- memory explorer cards (column layout) ---
440
+ // --- three-column browse layout: hairline separators, no outer box ---
441
+ ".mneme-xmain{flex:1;min-height:0;display:flex;flex-direction:row;overflow:hidden}",
442
+ ".mneme-xside{flex:none;width:236px;min-width:0;min-height:0;overflow-y:auto;padding:12px;border-right:1px solid var(--dsw-alias-border-l2);box-sizing:border-box;display:flex;flex-direction:column;gap:8px}",
443
+ ".mneme-xside--filter{width:214px}",
444
+ ".mneme-xbrowse{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;overflow:hidden}",
401
445
  ".mneme-xrow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
402
446
  ".mneme-xsearch{width:100%}",
403
447
  ".mneme-xselect{flex:1;min-width:0}",
@@ -422,35 +466,17 @@ window.__ModuleLoader__.load({
422
466
  ".mneme-xdot{flex:none;display:inline-block;width:8px;height:8px;border-radius:50%;background:currentColor}",
423
467
  ".mneme-xdot--all{background:transparent;border:1.5px solid currentColor;opacity:.55;box-sizing:border-box}",
424
468
  ".mneme-xitem .mneme-xdot{align-self:center}",
425
- // --- memory explorer root ---
426
- ".mneme-xmain{display:flex;flex-direction:column}",
427
- ".mneme-xfilter-bar{flex:none;height:48px;display:flex;align-items:center;gap:8px;padding:0 12px;border-bottom:1px solid var(--dsw-alias-border-l2,#ddd)}",
428
- ".mneme-xcards{flex:1;display:flex;flex-direction:row;gap:12px;padding:12px;overflow:hidden}",
429
- ".mneme-card{background:var(--dsw-alias-bg-base,#fafafa);border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.06);padding:12px;display:flex;flex-direction:column;overflow:auto}",
430
- ".mneme-card--search{width:236px;flex:none}",
431
- ".mneme-card--tree{width:260px;flex:none}",
432
- ".mneme-card--detail{flex:1;min-width:0}",
469
+ // --- detail column (preview pane sits BELOW the timeline: the list is
470
+ // the primary scan target, the detail grows only when something is
471
+ // selected so the empty state never eats the viewport) ---
472
+ ".mneme-xdetail{flex:none;max-height:56%;min-height:0;overflow-y:auto;padding:12px 20px 16px;border-top:1px solid var(--dsw-alias-border-l2)}",
473
+ ".mneme-xtree{flex:1;min-height:0;overflow-y:auto;padding:8px 10px 28px}",
433
474
  ".mneme-xdinner{max-width:720px}",
434
475
  ".mneme-xdtitle{font-size:16px;font-weight:600;line-height:24px;color:var(--dsw-alias-label-primary);margin-bottom:10px;word-break:break-word}",
435
476
  ".mneme-xdmeta{display:flex;flex-wrap:wrap;gap:4px 14px;margin-bottom:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}",
436
477
  ".mneme-xdsrc{display:inline-block;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom}",
437
478
  ".mneme-xdcontent{margin-top:14px;font-size:14px;line-height:1.75;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-word}",
438
479
  ".mneme-xdactions{display:flex;gap:8px;margin-top:18px}",
439
- // --- wiki-link backlinks panel (detail pane footer) ---
440
- ".mneme-backlinks{margin-top:18px;padding-top:14px;border-top:1px solid var(--dsw-alias-border-l1);display:flex;flex-direction:column;gap:12px}",
441
- ".mneme-bl-block{display:flex;flex-direction:column;gap:4px}",
442
- ".mneme-bl-head{font-size:12px;font-weight:500;color:var(--dsw-alias-label-tertiary)}",
443
- ".mneme-bl-list{display:flex;flex-wrap:wrap;gap:6px}",
444
- ".mneme-bl-link{display:inline-flex;align-items:center;gap:6px;max-width:100%;border:none;background:none;color:var(--dsw-alias-state-business-primary);cursor:pointer;font-family:inherit;font-size:13px;line-height:18px;padding:2px 8px;border-radius:6px;text-align:left}",
445
- ".mneme-bl-link:hover{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent)}",
446
- ".mneme-bl-link.mneme-bl-link--dim{color:var(--dsw-alias-label-tertiary);cursor:default}",
447
- ".mneme-bl-link.mneme-bl-link--dim:hover{background:none}",
448
- ".mneme-bl-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
449
- ".mneme-bl-meta{flex:none;font-size:11px;color:var(--dsw-alias-label-tertiary)}",
450
- ".mneme-bl-empty{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}",
451
- // --- inline [[wiki-link]] links inside the detail content ---
452
- ".mneme-wikilink{display:inline;padding:0 2px;border:none;background:none;color:var(--dsw-alias-state-business-primary);cursor:pointer;font-family:inherit;font-size:inherit;line-height:inherit;text-decoration:underline;text-underline-offset:2px;border-radius:3px}",
453
- ".mneme-wikilink:hover{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 8%,transparent)}",
454
480
  // --- graph sub-view (fills the content area under the tabs) ---
455
481
  ".mneme-graph{flex:1;min-height:0;width:100%;display:flex;flex-direction:column;padding:12px 16px 16px;box-sizing:border-box}",
456
482
  ".mneme-graphbar{display:flex;gap:8px;align-items:center;flex:none;margin-bottom:10px}",
@@ -472,38 +498,36 @@ window.__ModuleLoader__.load({
472
498
  ".mneme-gs-attrval{color:var(--dsw-alias-label-secondary);word-break:break-word;font-size:13px}",
473
499
  ".mneme-gs-link{display:block;width:100%;text-align:left;border:none;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-family:inherit;font-size:13px;padding:3px 6px;border-radius:6px;word-break:break-word}",
474
500
  ".mneme-gs-link:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
475
- // --- directory sub-view (v0.6.3): tag folders + memory entries ---
476
- ".mneme-directory{flex:1;min-height:0;overflow-y:auto;padding:8px 10px 28px}",
477
- ".mneme-edit-toggle{margin:6px 10px;display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer;user-select:none}",
478
- ".mneme-dir-item{position:relative;display:flex;align-items:center;gap:6px;padding:2px 6px;border-radius:4px}",
479
- ".mneme-dir-item:hover{background:rgba(127,127,127,.18)}",
480
- ".mneme-tree-line{width:14px;height:1px;border-top:1px dashed rgba(127,127,127,.5);flex:none}",
481
- ".mneme-dir-item .mneme-dir-del{opacity:0;transition:opacity .12s}",
482
- ".mneme-dir-item:hover .mneme-dir-del{opacity:1}",
483
- ".mneme-dir-del{margin-left:auto;background:none;border:none;color:#e5484d;cursor:pointer;font-size:13px;line-height:1;padding:2px 4px}",
484
- ".mneme-dir-del:hover{filter:brightness(1.25)}",
485
- ".mneme-edit-on .mneme-dir-del{opacity:1}",
486
- ".mneme-dir-del-confirm{font-weight:700;background:rgba(229,72,77,.12);border-radius:4px}",
487
- ".mneme-dir-item.mneme-dir-error{background:rgba(229,72,77,.14)}",
488
- ".mneme-dir-del-err{flex:none;font-size:12px;line-height:16px;color:#e5484d}",
489
- ".mneme-dir-ititle{background:none;border:none;padding:0;font:inherit;color:inherit;cursor:pointer;flex:1;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
490
- ".mneme-dir-folder{margin-bottom:4px}",
491
- ".mneme-dir-folderhead{display:flex;align-items:center;gap:6px;width:100%;padding:6px 8px;border:none;border-radius:8px;background:none;color:var(--dsw-alias-label-primary);cursor:pointer;font-family:inherit;font-size:13px;font-weight:500;line-height:18px;text-align:left}",
492
- ".mneme-dir-folderhead:hover{background:var(--dsw-alias-interactive-bg-hover)}",
493
- ".mneme-dir-caret{flex:none;width:10px;text-align:center;color:var(--dsw-alias-label-tertiary);font-size:12px}",
494
- ".mneme-dir-fname{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
495
- ".mneme-dir-fcount{flex:none;margin-left:auto;font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}",
496
- ".mneme-dir-fbody{margin:2px 0 4px 22px;padding-left:8px;border-left:1px solid var(--dsw-alias-border-l2)}",
497
- ".mneme-dir-item{display:flex;gap:8px;align-items:baseline;width:100%;padding:4px 8px;border:none;border-radius:8px;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-family:inherit;font-size:13px;line-height:18px;text-align:left}",
498
- ".mneme-dir-item:hover{background:var(--dsw-alias-interactive-bg-hover)}",
499
- ".mneme-dir-item.mneme-active{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary)}",
500
- ".mneme-dir-ititle{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
501
- ".mneme-dir-itime{flex:none;font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}",
502
- ".mneme-dir-empty{color:var(--dsw-alias-label-tertiary);padding:24px 0;text-align:center;font-size:13px}",
503
- // --- settings sub-view ---
504
- ".mneme-xsettings{flex:1;min-height:0;overflow-y:auto;padding:8px 24px 48px;box-sizing:border-box}",
501
+ // --- entity explorer: rail + layered detail (entities attrs relations graph) ---
502
+ ".mneme-ent{flex:1;min-height:0;display:flex;overflow:hidden}",
503
+ ".mneme-entrail{flex:none;width:224px;min-height:0;overflow-y:auto;padding:12px;border-right:1px solid var(--dsw-alias-border-l2);box-sizing:border-box;display:flex;flex-direction:column;gap:4px}",
504
+ ".mneme-enttype{display:flex;align-items:center;gap:6px;width:100%;padding:8px 8px 2px;border:none;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-family:inherit;font-size:11px;font-weight:600;line-height:16px;text-align:left;letter-spacing:.02em}",
505
+ ".mneme-entitem{display:flex;align-items:center;gap:8px;width:100%;padding:5px 8px;border:none;border-radius:8px;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-family:inherit;font-size:13px;line-height:18px;text-align:left}",
506
+ ".mneme-entitem:hover{background:var(--dsw-alias-interactive-bg-hover)}",
507
+ ".mneme-entitem.mneme-active{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary)}",
508
+ ".mneme-entname{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
509
+ ".mneme-entmentions{flex:none;font-size:11px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}",
510
+ ".mneme-entdetail{flex:1;min-width:0;min-height:0;overflow-y:auto;padding:20px 24px 32px;box-sizing:border-box}",
511
+ ".mneme-entinner{max-width:720px}",
512
+ ".mneme-enttitle{display:flex;align-items:center;gap:10px;font-size:17px;font-weight:600;line-height:24px;color:var(--dsw-alias-label-primary);word-break:break-word}",
513
+ ".mneme-enttitle .mneme-xdot{width:10px;height:10px}",
514
+ ".mneme-entmeta{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}",
515
+ ".mneme-layer{margin-top:24px}",
516
+ ".mneme-layerhead{display:flex;align-items:baseline;gap:8px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin-bottom:10px}",
517
+ ".mneme-layercount{font-size:12px;font-weight:400;color:var(--dsw-alias-label-tertiary)}",
518
+ ".mneme-attrgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px}",
519
+ ".mneme-attrcard{min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:8px 12px}",
520
+ ".mneme-attrkey{font-size:11px;color:var(--dsw-alias-label-tertiary);margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
521
+ ".mneme-attrval{font-size:13px;line-height:19px;color:var(--dsw-alias-label-primary);word-break:break-word}",
522
+ ".mneme-relrow{display:flex;align-items:center;gap:10px;width:100%;padding:7px 10px;border:none;border-radius:10px;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-family:inherit;font-size:13px;line-height:20px;text-align:left}",
523
+ ".mneme-relrow:hover{background:var(--dsw-alias-interactive-bg-hover)}",
524
+ ".mneme-reltype{flex:none;font-size:11px;line-height:16px;color:var(--dsw-alias-state-business-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-state-business-primary) 35%,transparent);border-radius:6px;padding:0 6px}",
525
+ ".mneme-relname{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
526
+ // --- settings: quiet stacked sections, one concern per section ---
527
+ ".mneme-set{flex:1;min-height:0;overflow-y:auto;padding:8px 24px 48px;box-sizing:border-box}",
528
+ ".mneme-set-inner{max-width:680px}",
505
529
  ".mneme-set-sec{padding:20px 0 24px;border-bottom:1px solid var(--dsw-alias-border-l2)}",
506
- ".mneme-xsettings .mneme-set-sec:last-child{border-bottom:none}",
530
+ ".mneme-set-sec:last-child{border-bottom:none}",
507
531
  ".mneme-set-title{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary);margin-bottom:4px}",
508
532
  ".mneme-set-desc{font-size:13px;line-height:19px;color:var(--dsw-alias-label-tertiary);margin-bottom:14px}",
509
533
  ".mneme-set-input{box-sizing:border-box;width:100%;height:34px;padding:0 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base,transparent);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:13px;outline:none;margin-bottom:10px;transition:border-color .12s,box-shadow .12s}",
@@ -514,6 +538,7 @@ window.__ModuleLoader__.load({
514
538
  ".mneme-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}",
515
539
  ".mneme-btn:disabled{opacity:.5;cursor:default}",
516
540
  ".mneme-saved{font-size:12px;color:var(--dsw-alias-state-success,#2a7)}",
541
+ // rule rows: numbered chips + hover-revealed delete
517
542
  ".mneme-set-row{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;margin-bottom:8px;background:var(--dsw-alias-bg-base,transparent)}",
518
543
  ".mneme-set-idx{flex:none;width:20px;height:20px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:18px;display:inline-flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums}",
519
544
  ".mneme-set-ruletext{flex:1;min-width:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);word-break:break-word}",
@@ -523,47 +548,46 @@ window.__ModuleLoader__.load({
523
548
  ".mneme-set-cmd{flex:1;min-width:0}",
524
549
  ".mneme-set-cmdname{font-size:13px;font-weight:600;color:var(--dsw-alias-state-business-primary)}",
525
550
  ".mneme-set-cmddesc{font-size:12px;line-height:17px;color:var(--dsw-alias-label-tertiary);margin-top:1px;word-break:break-word}",
526
- ".mneme-xsettings-inner{max-width:640px}",
527
- // --- overview sub-view (layered stats dashboard, .mneme-ov-* only) ---
528
- ".mneme-ov-wrap{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:12px;padding:12px 16px 28px}",
529
- ".mneme-ov-profile{gap:8px}",
530
- ".mneme-ov-prow{display:flex;flex-direction:column;gap:2px;padding:4px 0;border-top:1px solid var(--dsw-alias-border-l1)}",
531
- ".mneme-ov-prow:first-of-type{border-top:none}",
532
- ".mneme-ov-ptitle{font-size:13px;font-weight:600;line-height:18px;color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
533
- ".mneme-ov-ppreview{font-size:12px;line-height:17px;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
534
- ".mneme-ov-dist{display:flex;flex-direction:column;gap:6px}",
535
- ".mneme-ov-distrow{display:flex;align-items:center;gap:8px}",
536
- ".mneme-ov-distlabel{flex:none;width:76px;font-size:12px;line-height:16px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
537
- ".mneme-ov-distcount{flex:none;width:34px;font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;text-align:right}",
538
- ".mneme-ov-disttrack{flex:1;min-width:0;height:8px;border-radius:4px;background:var(--dsw-alias-border-l2)}",
539
- ".mneme-ov-distbar{height:100%;min-width:2px;border-radius:4px;background:var(--dsw-alias-state-business-primary)}",
540
- ".mneme-ov-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:10px}",
541
- ".mneme-ov-tile{box-sizing:border-box;text-align:left;border:none;cursor:pointer;font-family:inherit;gap:6px;overflow:hidden}",
542
- ".mneme-ov-tile:hover{background:var(--dsw-alias-interactive-bg-hover)}",
543
- ".mneme-ov-tilehead{display:flex;align-items:center;gap:6px}",
544
- ".mneme-ov-tiletitle{min-width:0;font-size:13px;font-weight:600;line-height:18px;color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
545
- ".mneme-ov-tilecount{flex:none;margin-left:auto;font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}",
546
- ".mneme-ov-badge{flex:none;font-size:11px;line-height:16px;padding:0 6px;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent);color:var(--dsw-alias-state-business-primary)}",
547
- ".mneme-ov-badge--dim{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-tertiary)}",
548
- ".mneme-ov-tilelatest{font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
549
- ".mneme-ov-trend{display:flex;align-items:flex-end;gap:8px;padding:8px 4px 0}",
550
- ".mneme-ov-trendcol{flex:1;min-width:0;display:flex;flex-direction:column;align-items:center;gap:4px}",
551
- ".mneme-ov-trendbar{width:min(100%,30px);border-radius:4px 4px 0 0;background:var(--dsw-alias-state-business-primary)}",
552
- ".mneme-ov-trenddate{font-size:10px;line-height:12px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;white-space:nowrap}",
551
+ // --- settings sub-view ---
552
+ ".mneme-set{flex:1;min-height:0;overflow-y:auto;padding:8px 24px 48px;box-sizing:border-box}",
553
553
  // --- hero fallback: full-viewport memory library when no tab ring exists ---
554
554
  // The host hides the whole conversation tab ring while a session is
555
555
  // blank (hero screen), so the sidebar entry cannot activate the tab
556
556
  // there. This surface reuses the exact MemoryExplorer UI at full size —
557
557
  // not a side drawer — so the library stays reachable from any state.
558
558
  ".mneme-overlay{position:fixed;inset:0;z-index:1000;display:flex;flex-direction:column;background:var(--dsw-alias-bg-layer-1);animation:mneme-fadein .12s ease-out}",
559
- // issue #41: overlay 顶栏右侧原本与宿主窗口标题栏的控制按钮(最小化/最大化/关闭)
560
- // 落在同一区域——宿主控制按钮浮在 web 内容最上层,抢占了 overlay"关闭"按钮的
561
- // 点击热区,导致记忆窗口无法关闭。改为左对齐(标题 + 关闭按钮并排),关闭按钮
562
- // 离开右上角宿主控制按钮区,任何窗口尺寸/宿主下都可见可点。
563
- ".mneme-overlaybar{flex:none;display:flex;align-items:center;justify-content:flex-start;gap:12px;height:44px;padding:0 12px 0 16px;border-bottom:1px solid var(--dsw-alias-border-l2)}",
559
+ ".mneme-overlaybar{flex:none;display:flex;align-items:center;justify-content:space-between;height:44px;padding:0 12px 0 16px;border-bottom:1px solid var(--dsw-alias-border-l2)}",
564
560
  ".mneme-overlaytitle{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}",
565
561
  ".mneme-overlaybody{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}",
566
- "@keyframes mneme-fadein{from{opacity:0}to{opacity:1}}"
562
+ "@keyframes mneme-fadein{from{opacity:0}to{opacity:1}}",
563
+ // --- explorer refresh: paged month tree, sticky headers, inline icons ---
564
+ ".mneme-vtabico{flex:none;opacity:.85}",
565
+ ".mneme-xsearchwrap{position:relative;flex:none}",
566
+ ".mneme-xsearchico{position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--dsw-alias-label-tertiary);pointer-events:none}",
567
+ ".mneme-xsearchwrap .mneme-xsearch{padding-left:27px}",
568
+ ".mneme-footbtn{display:inline-flex;align-items:center;gap:4px}",
569
+ ".mneme-xmonth{position:sticky;top:0;z-index:2;background:var(--dsw-alias-bg-layer-1);display:flex;align-items:center;gap:6px}",
570
+ ".mneme-xmonthcount{margin-left:auto;color:var(--dsw-alias-label-tertiary);font-weight:400;font-variant-numeric:tabular-nums}",
571
+ ".mneme-xcaret{display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-tertiary)}",
572
+ ".mneme-xmore{display:flex;justify-content:center;align-items:center;padding:10px 0 24px;min-height:20px}",
573
+ ".mneme-xemptyico{display:block;margin:0 auto 6px;opacity:.7}",
574
+ // --- status sub-view: responsive stat-card grid (auto-fill, ~220px min) ---
575
+ ".mneme-status{flex:1;min-height:0;overflow-y:auto;padding:16px 20px 32px;box-sizing:border-box}",
576
+ ".mneme-statusgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;max-width:960px}",
577
+ ".mneme-statuscard{min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:14px}",
578
+ ".mneme-statusnum{font-size:24px;font-weight:600;line-height:32px;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}",
579
+ ".mneme-statuscap{margin-top:4px;font-size:13px;line-height:19px;color:var(--dsw-alias-label-tertiary);word-break:break-word}",
580
+ // --- destructive actions: red outline = delete, solid red = confirm ---
581
+ ".mneme-btndanger{color:var(--dsw-alias-state-error,#c33);border-color:var(--dsw-alias-state-error,#c33)}",
582
+ ".mneme-btndanger:hover{background:color-mix(in srgb,var(--dsw-alias-state-error,#c33) 12%,transparent)}",
583
+ ".mneme-btndangerconfirm{background:var(--dsw-alias-state-error,#c33);border-color:var(--dsw-alias-state-error,#c33);color:#fff}",
584
+ ".mneme-btndangerconfirm:hover{filter:brightness(.9)}",
585
+ // --- settings cards: boxed cards for the runtime-mode and external-API
586
+ // sections — each card owns its fetch/PUT state, so it renders as a
587
+ // self-contained unit inside the stacked settings view ---
588
+ ".mneme-set-card{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:14px;margin-bottom:12px}",
589
+ ".mneme-set-token{font-family:monospace;font-size:12px;padding:7px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-base,transparent);color:var(--dsw-alias-label-primary);word-break:break-all;user-select:all}",
590
+ ".mneme-set-hint{font-size:12px;color:var(--dsw-alias-label-tertiary)}"
567
591
  ].join("\n");
568
592
  if (typeof document !== "undefined" && document.querySelector(`style[data-plugin-css="${CSS_TAG}"]`) === null) {
569
593
  const tag = document.createElement("style");
@@ -599,32 +623,28 @@ window.__ModuleLoader__.load({
599
623
  project: "#22c55e",
600
624
  decision: "#3b82f6",
601
625
  summary: "#a855f7",
602
- user: "#f59e0b",
603
- fact: "#22c55e",
604
626
  history: "#94a3b8"
605
627
  };
606
628
  function memoryTypeColor(type) {
607
629
  return MEMORY_TYPE_COLORS[type] || "#94a3b8";
608
630
  }
609
631
 
610
- // Compact date for the detail meta ("2026/9/7"); the full timestamp
632
+ // Compact date for the detail meta ("2026/8/16"); the full timestamp
611
633
  // stays in the tooltip.
612
634
  function formatDateShort(value) {
613
635
  if (!value) return "—";
614
636
  const date = new Date(value);
615
637
  return Number.isNaN(date.getTime()) ? "—" : date.toLocaleDateString();
616
638
  }
639
+ // Graph canvas space: the svg's fixed viewBox. The simulation runs in
640
+ // these user units — never in CSS pixels — so node positions always land
641
+ // inside the drawn area; the browser scales the viewBox to whatever the
642
+ // element's CSS size is (small window, hidden tab, maximized #72).
643
+ const VIEW_W = 380, VIEW_H = 300;
644
+
617
645
  function nodeRadius(n) {
618
646
  // mention_count → area-ish growth, clamped so hubs stay legible.
619
- const base = 7 + Math.min(20, Math.max(1, n.mention_count ?? 1)) * 0.55;
620
- // v0.7.0 heat: hot entities render slightly larger; cold → base.
621
- if (n.heat == null) return base;
622
- return base + (Math.max(0, Math.min(1, n.heat)) - 0.5) * 4;
623
- }
624
- function nodeOpacity(n) {
625
- // v0.7.0 heat: hot entities are bright (opacity 1), cold fade to 0.4.
626
- if (n.heat == null) return 1;
627
- return 0.4 + 0.6 * Math.max(0, Math.min(1, n.heat));
647
+ return 7 + Math.min(20, Math.max(1, n.mention_count ?? 1)) * 0.55;
628
648
  }
629
649
 
630
650
  // Deterministic golden-angle spiral: no two nodes start overlapping, and
@@ -639,100 +659,17 @@ window.__ModuleLoader__.load({
639
659
  });
640
660
  }
641
661
 
642
- // --- wiki-link (v0.6.1) -------------------------------------------------
643
- // Splits detail content into literal text and clickable [[target]] /
644
- // [[display|target]] links. Mirrors src/parser/wiki-link.js: single pipe
645
- // only, empty / multi-pipe / unclosed markers stay literal text.
646
- function wikilinkSegments(text, onClick) {
647
- if (typeof text !== "string" || text.length === 0) return text;
648
- const out = [];
649
- let last = 0;
650
- let key = 0;
651
- const re = /\[\[([^\[\]]*)\]\]/g;
652
- let m;
653
- while ((m = re.exec(text)) !== null) {
654
- const parts = m[1].split("|");
655
- if (parts.length > 2) continue; // 多管道 → 非法,保留字面文本
656
- const target = (parts.length === 2 ? parts[1] : parts[0]).trim();
657
- if (!target) continue; // 空目标 → 非法
658
- if (m.index > last) out.push(text.slice(last, m.index));
659
- out.push(h("button", {
660
- key: `w${key++}`,
661
- type: "button",
662
- className: "mneme-wikilink",
663
- title: target,
664
- onClick: () => onClick(target)
665
- }, parts[0].trim() || target));
666
- last = m.index + m[0].length;
667
- }
668
- if (last < text.length) out.push(text.slice(last));
669
- return out.length ? out : text;
670
- }
671
-
672
- // Backlinks panel: the two wiki-link relation blocks under the detail pane.
673
- // Backlinks = memories whose content links to this one; forward = memories
674
- // this one links to (unresolved targets surface as dim, non-clickable).
675
- // Mounted per-selection (the surrounding Fragment keys on selected.id), so
676
- // state resets and the fetches re-run on every switch.
677
- function BacklinksPanel({ memory, t, onJump }) {
678
- const [back, setBack] = useState(null); // null = loading
679
- const [forward, setForward] = useState(null);
680
-
681
- useEffect(() => {
682
- let cancelled = false;
683
- if (!memory || !memory.id) return;
684
- apiFetch(`/api/dsh-mneme/wikilinks/backlinks?id=${encodeURIComponent(memory.id)}`)
685
- .then((r) => (r.ok ? r.json() : { backlinks: [] }))
686
- .then((j) => { if (!cancelled) setBack(Array.isArray(j.backlinks) ? j.backlinks : []); })
687
- .catch(() => { if (!cancelled) setBack([]); });
688
- apiFetch(`/api/dsh-mneme/wikilinks/forward?id=${encodeURIComponent(memory.id)}`)
689
- .then((r) => (r.ok ? r.json() : { links: [] }))
690
- .then((j) => { if (!cancelled) setForward(Array.isArray(j.links) ? j.links : []); })
691
- .catch(() => { if (!cancelled) setForward([]); });
692
- return () => { cancelled = true; };
693
- }, [memory && memory.id]);
694
-
695
- if (!memory || !memory.id) return null;
696
-
697
- const loading = back === null || forward === null;
698
- const row = (link, i) => {
699
- const canJump = !!link.id;
700
- return h("button", {
701
- key: link.id ?? `unresolved-${i}`,
702
- type: "button",
703
- className: canJump ? "mneme-bl-link" : "mneme-bl-link mneme-bl-link--dim",
704
- title: canJump ? t("memory.card.open") : t("memory.wikilink.unresolved"),
705
- disabled: !canJump,
706
- onClick: () => onJump && onJump({ id: link.id })
707
- },
708
- h("span", { className: "mneme-bl-title" }, link.title || "—"),
709
- link.type && h("span", { className: "mneme-bl-meta" }, typeLabel(t, link.type))
710
- );
711
- };
712
- const section = (label, items) =>
713
- h("div", { className: "mneme-bl-block" },
714
- h("div", { className: "mneme-bl-head" }, label),
715
- loading
716
- ? h("div", { className: "mneme-bl-empty" }, t("memory.wikilink.loading"))
717
- : items.length === 0
718
- ? h("div", { className: "mneme-bl-empty" }, t("memory.wikilink.empty"))
719
- : h("div", { className: "mneme-bl-list" }, items.map(row))
720
- );
721
-
722
- return h("div", { className: "mneme-backlinks" },
723
- section(t("memory.wikilink.backlinks"), back || []),
724
- section(t("memory.wikilink.forward"), forward || [])
725
- );
726
- }
727
-
728
- // --- Graph view: ego-graph of one entity, zero-dependency SVG force layout ---
729
- // The DSH client module table only whitelists platform modules, so a graph
730
- // library like vis-network cannot be required from a plugin. A hand-rolled
731
- // spring simulation (repulsion + edge springs + center gravity, damped) is
732
- // plenty for the ≤40 nodes the ego API returns.
733
- function GraphPanel({ t, focusEntity, onJumpMemory }) {
662
+ // --- Entity explorer: the entity-gene layers made visible ---
663
+ // Rail = the entity directory (grouped by type, mention-ranked inside a
664
+ // type). Detail = the layered structure from docs/ENTITIES.md:
665
+ // 属性 current snapshot of entity_attrs (saveAttr invalidates old rows)
666
+ // ② 关系 entity_relations touching the entity (from the 1-hop ego walk)
667
+ // ③ 关联记忆 memories recalled through the entity: prefix search
668
+ // ④ 关系图谱 the same ego graph the old graph-only tab drew
669
+ function EntityPanel({ t, focusEntity, onJumpMemory }) {
670
+ const [entities, setEntities] = useState(null); // null = still loading
671
+ const [railQuery, setRailQuery] = useState("");
734
672
  const [entityName, setEntityName] = useState(focusEntity || "");
735
- const [inputValue, setInputValue] = useState(focusEntity || "");
736
673
  const [depth, setDepth] = useState(1);
737
674
  const [data, setData] = useState(null);
738
675
  const [status, setStatus] = useState("idle"); // idle | loading | ready | notfound | error
@@ -745,14 +682,29 @@ window.__ModuleLoader__.load({
745
682
  const viewRef = useRef({ x: 0, y: 0, k: 1 }); // pan/zoom viewport, viewBox units
746
683
  const [frame, setFrame] = useState(0); // re-render tick driven by the simulation
747
684
 
685
+ useEffect(() => {
686
+ let cancelled = false;
687
+ apiFetch("/api/dsh-mneme/entities?limit=500")
688
+ .then((r) => (r.ok ? r.json() : { entities: [] }))
689
+ .then((j) => { if (!cancelled) setEntities(Array.isArray(j.entities) ? j.entities : []); })
690
+ .catch(() => { if (!cancelled) setEntities([]); });
691
+ return () => { cancelled = true; };
692
+ }, []);
693
+
748
694
  useEffect(() => {
749
695
  if (focusEntity && focusEntity !== entityName) {
750
696
  setEntityName(focusEntity);
751
- setInputValue(focusEntity);
752
697
  }
753
698
  // eslint-disable-next-line react-hooks/exhaustive-deps
754
699
  }, [focusEntity]);
755
700
 
701
+ // Auto-select the most recently seen entity so the layers are never a
702
+ // dead end on first open (the rail still allows picking another one).
703
+ useEffect(() => {
704
+ if (!entities || entityName || entities.length === 0) return;
705
+ setEntityName(entities[0].name);
706
+ }, [entities, entityName]);
707
+
756
708
  const load = useCallback(async (name, d) => {
757
709
  if (!name) { setData(null); setStatus("idle"); return; }
758
710
  setStatus("loading");
@@ -772,31 +724,32 @@ window.__ModuleLoader__.load({
772
724
 
773
725
  useEffect(() => { load(entityName, depth); }, [load, entityName, depth]);
774
726
 
775
- // Detail pane: node current attrs + entity: search for related memories.
727
+ // Layer data for the selected entity: current attrs + related memories.
776
728
  useEffect(() => {
777
729
  setAttrs([]);
778
730
  setRelated([]);
779
- if (!selected || selected.kind !== "node") return;
780
- const name = selected.node.name;
731
+ if (!entityName) return;
781
732
  let cancelled = false;
782
- apiFetch(`/api/dsh-mneme/semantic/graph/entity-attrs?entity=${encodeURIComponent(name)}`)
733
+ apiFetch(`/api/dsh-mneme/semantic/graph/entity-attrs?entity=${encodeURIComponent(entityName)}`)
783
734
  .then((r) => (r.ok ? r.json() : { attrs: [] }))
784
735
  .then((j) => { if (!cancelled) setAttrs(Array.isArray(j.attrs) ? j.attrs : []); })
785
736
  .catch(() => {});
786
- apiFetch(`/api/dsh-mneme/search?q=${encodeURIComponent("entity:" + name)}&limit=10`)
737
+ apiFetch(`/api/dsh-mneme/search?q=${encodeURIComponent("entity:" + entityName)}&limit=10`)
787
738
  .then((r) => (r.ok ? r.json() : { items: [] }))
788
739
  .then((j) => { if (!cancelled) setRelated(Array.isArray(j.items) ? j.items : []); })
789
740
  .catch(() => {});
790
741
  return () => { cancelled = true; };
791
- }, [selected]);
742
+ }, [entityName]);
792
743
 
793
744
  // Simulation: run in rAF against posRef, tick React only every few frames.
794
745
  // Re-seeds when data changes; dragging writes straight into posRef.
795
746
  useEffect(() => {
796
747
  if (!data || data.nodes.length === 0) return;
797
- const width = Math.max(280, svgRef.current?.clientWidth || 380);
798
- const height = 300;
799
- posRef.current = initialPositions(data.nodes, width, height);
748
+ // Bounds in viewBox units: measuring clientWidth here put the gravity
749
+ // center and clamp range outside the 380-wide viewBox once the window
750
+ // (or its hidden-then-shown container) got wide, and every node was
751
+ // clipped off-canvas — the maximized-invisible bug (#72).
752
+ posRef.current = initialPositions(data.nodes, VIEW_W, VIEW_H);
800
753
  const byId = new Map(posRef.current.map((n) => [n.id, n]));
801
754
  const edges = data.edges;
802
755
  let raf = 0;
@@ -829,12 +782,12 @@ window.__ModuleLoader__.load({
829
782
  let energy = 0;
830
783
  for (const node of nodes) {
831
784
  // gentle gravity toward center keeps the cloud from drifting off-canvas
832
- node.vx += (width / 2 - node.x) * 0.002;
833
- node.vy += (height / 2 - node.y) * 0.002;
785
+ node.vx += (VIEW_W / 2 - node.x) * 0.002;
786
+ node.vy += (VIEW_H / 2 - node.y) * 0.002;
834
787
  if (node.pinned || node === dragRef.current?.node) { node.vx = 0; node.vy = 0; continue; }
835
788
  node.vx *= 0.85; node.vy *= 0.85;
836
- node.x = Math.max(nodeRadius(node) + 4, Math.min(width - nodeRadius(node) - 4, node.x + node.vx));
837
- node.y = Math.max(nodeRadius(node) + 14, Math.min(height - nodeRadius(node) - 14, node.y + node.vy));
789
+ node.x = Math.max(nodeRadius(node) + 4, Math.min(VIEW_W - nodeRadius(node) - 4, node.x + node.vx));
790
+ node.y = Math.max(nodeRadius(node) + 14, Math.min(VIEW_H - nodeRadius(node) - 14, node.y + node.vy));
838
791
  energy += Math.abs(node.vx) + Math.abs(node.vy);
839
792
  }
840
793
  n++;
@@ -854,7 +807,7 @@ window.__ModuleLoader__.load({
854
807
  const origX = node.x, origY = node.y;
855
808
  const svg = svgRef.current;
856
809
  const rect = svg.getBoundingClientRect();
857
- const scale = 380 / Math.max(1, rect.width) / viewRef.current.k; // viewBox units per css px, zoom-aware
810
+ const scale = VIEW_W / Math.max(1, rect.width) / viewRef.current.k; // viewBox units per css px, zoom-aware
858
811
  const onMove = (ev) => {
859
812
  dragRef.current.moved = true;
860
813
  node.x = origX + (ev.clientX - startX) * scale;
@@ -870,6 +823,12 @@ window.__ModuleLoader__.load({
870
823
  window.addEventListener("mouseup", onUp);
871
824
  };
872
825
 
826
+ const onNodeClick = (node) => {
827
+ if (dragRef.current?.moved) return; // it was a drag, not a click
828
+ setSelected({ kind: "node", node });
829
+ };
830
+ const onEdgeClick = (edge) => setSelected({ kind: "edge", edge });
831
+
873
832
  // --- canvas pan & zoom ---
874
833
  // The viewport (translate + scale) lives in viewRef so the simulation
875
834
  // and the handlers share it without re-mounting; every mutation bumps
@@ -934,39 +893,71 @@ window.__ModuleLoader__.load({
934
893
  setFrame((f) => f + 1);
935
894
  };
936
895
 
937
- const onNodeClick = (node) => {
938
- if (dragRef.current?.moved) return; // it was a drag, not a click
939
- setSelected({ kind: "node", node });
940
- };
941
- const onEdgeClick = (edge) => setSelected({ kind: "edge", edge });
942
-
943
896
  const nodes = posRef.current;
944
897
  const nodeById = new Map(nodes.map((n) => [n.id, n]));
945
- const VIEW_W = 380, VIEW_H = 300;
946
898
 
899
+ // --- rail: entity directory grouped by type ---
900
+ const q = railQuery.trim().toLowerCase();
901
+ const visibleEntities = (entities || []).filter((e) => !q || (e.name || "").toLowerCase().includes(q));
902
+ const ENTITY_TYPE_ORDER = ["person", "project", "concept", "technology", "organization"];
903
+ const groups = [];
904
+ const byType = new Map();
905
+ for (const e of visibleEntities) {
906
+ const key = ENTITY_TYPE_ORDER.includes(e.type) ? e.type : "other";
907
+ if (!byType.has(key)) { const g = { key, items: [] }; byType.set(key, g); groups.push(g); }
908
+ byType.get(key).items.push(e);
909
+ }
910
+
911
+ const entity = entities?.find((e) => e.name === entityName) || null;
912
+ const rootEdges = (data?.edges || []).filter((e) => e.from === data?.root?.id || e.to === data?.root?.id);
913
+ const graphStatus = status === "ready" && data && data.nodes.length <= 1 ? "empty" : status;
914
+
915
+ const rail = h("div", { className: "mneme-entrail" },
916
+ h("div", { className: "mneme-xcolhead" }, t("memory.view.label")),
917
+ h("input", {
918
+ className: "mneme-search mneme-xsearch",
919
+ placeholder: t("memory.graph.placeholder"),
920
+ value: railQuery,
921
+ onChange: (e) => setRailQuery(e.target.value)
922
+ }),
923
+ entities === null && h("div", { className: "mneme-xempty" }, "…"),
924
+ entities !== null && entities.length === 0 && h("div", { className: "mneme-xempty" }, t("memory.entities.none")),
925
+ entities !== null && entities.length > 0 && h("div", { className: "mneme-xcount" },
926
+ t("memory.entities.count").replace("{n}", String(entities.length))),
927
+ groups.map((g) => h("div", { key: g.key },
928
+ h("div", { className: "mneme-enttype" },
929
+ h("span", { className: "mneme-xdot", style: { color: typeColor(g.key === "other" ? null : g.key) }, "aria-hidden": "true" }),
930
+ entityTypeLabel(t, g.key)
931
+ ),
932
+ g.items.map((e) => h("button", {
933
+ key: e.id,
934
+ className: e.name === entityName ? "mneme-entitem mneme-active" : "mneme-entitem",
935
+ onClick: () => setEntityName(e.name)
936
+ },
937
+ h("span", { className: "mneme-entname" }, e.name),
938
+ h("span", { className: "mneme-entmentions" }, `×${e.mention_count ?? 1}`)
939
+ ))
940
+ ))
941
+ );
942
+
943
+ // side card for a clicked (non-root) node or edge inside the graph layer
947
944
  const side = selected?.kind === "node"
948
945
  ? h("div", { className: "mneme-graphside" },
949
946
  h("div", { className: "mneme-gs-title" },
950
947
  h("span", null, selected.node.name),
951
948
  h("span", { className: "mneme-gs-meta" },
952
- `${typeLabel(t, selected.node.type || "concept")} · ★${selected.node.mention_count ?? 1}`)
949
+ `${entityTypeLabel(t, selected.node.type || "concept")} · ★${selected.node.mention_count ?? 1}`)
953
950
  ),
951
+ selected.node.name !== entityName && h("button", {
952
+ className: "mneme-footbtn",
953
+ onClick: () => setEntityName(selected.node.name)
954
+ }, t("memory.graph.viewInGraph")),
954
955
  attrs.length > 0 && h("div", null,
955
956
  h("div", { className: "mneme-gs-meta" }, t("memory.graph.attrs")),
956
957
  attrs.map((a, i) => h("div", { key: i, className: "mneme-gs-attr" },
957
958
  h("span", { className: "mneme-gs-attrkey" }, `${a.key}:`),
958
959
  h("span", { className: "mneme-gs-attrval" }, a.value)
959
960
  ))
960
- ),
961
- related.length > 0 && h("div", { style: { marginTop: 8 } },
962
- h("div", { className: "mneme-gs-meta" }, t("memory.graph.related")),
963
- related.map((m) => h("button", {
964
- key: m.id,
965
- type: "button",
966
- className: "mneme-gs-link",
967
- title: t("memory.card.open"),
968
- onClick: () => onJumpMemory && onJumpMemory(m)
969
- }, m.title || m.content?.slice(0, 60)))
970
961
  )
971
962
  )
972
963
  : selected?.kind === "edge"
@@ -984,77 +975,140 @@ window.__ModuleLoader__.load({
984
975
  )
985
976
  : null;
986
977
 
987
- return h("div", { className: "mneme-graph" },
988
- h("div", { className: "mneme-graphbar" },
989
- h("input", {
990
- className: "mneme-search",
991
- style: { flex: 1, minWidth: 0, maxWidth: 280 },
992
- placeholder: t("memory.graph.placeholder"),
993
- value: inputValue,
994
- onChange: (e) => setInputValue(e.target.value),
995
- onKeyDown: (e) => { if (e.key === "Enter") setEntityName(inputValue.trim()); }
996
- }),
997
- h("button", {
998
- className: depth === 2 ? "mneme-chip mneme-active" : "mneme-chip",
999
- title: t("memory.graph.depth"),
1000
- onClick: () => setDepth(depth === 1 ? 2 : 1)
1001
- }, `${depth} ${t("memory.graph.depth")}`),
1002
- h("button", {
1003
- className: "mneme-chip",
1004
- title: t("memory.graph.resetView"),
1005
- onClick: resetView
1006
- }, t("memory.graph.resetView"))
1007
- ),
1008
- status === "idle" && h("div", { className: "mneme-hint" }, t("memory.graph.empty")),
1009
- status === "loading" && h("div", { className: "mneme-hint" }, t("memory.graph.loading")),
1010
- status === "notfound" && h("div", { className: "mneme-hint" }, t("memory.graph.notFound")),
1011
- status === "error" && h("div", { className: "mneme-hint" }, t("memory.panel.empty")),
1012
- status === "ready" && (data.nodes.length <= 1
1013
- ? h("div", { className: "mneme-hint" }, t("memory.graph.empty"))
1014
- : h(react.Fragment, null,
1015
- h("svg", {
1016
- ref: svgRef,
1017
- className: "mneme-graphsvg",
1018
- viewBox: `0 0 ${VIEW_W} ${VIEW_H}`,
1019
- style: { height: 320 },
1020
- onMouseDown: onSurfaceMouseDown
1021
- },
1022
- h("g", {
1023
- transform: `translate(${viewRef.current.x},${viewRef.current.y}) scale(${viewRef.current.k})`
1024
- },
1025
- data.edges.map((e) => {
1026
- const a = nodeById.get(e.from), b = nodeById.get(e.to);
1027
- if (!a || !b) return null;
1028
- return h("line", {
1029
- key: e.id,
1030
- x1: a.x, y1: a.y, x2: b.x, y2: b.y,
1031
- className: e.memory_id ? "mneme-gedge" : "mneme-gedge mneme-gedge-dashed",
1032
- onClick: () => onEdgeClick(e)
1033
- });
1034
- }),
1035
- nodes.map((n) => h("g", {
1036
- key: n.id,
1037
- className: n.id === data.root.id ? "mneme-gnode mneme-groot" : "mneme-gnode",
1038
- transform: `translate(${n.x},${n.y})`,
1039
- onMouseDown: (e) => onNodeMouseDown(e, n),
1040
- onClick: () => onNodeClick(n),
1041
- "data-node": n.name
1042
- },
1043
- h("circle", { r: nodeRadius(n), fill: typeColor(n.type), fillOpacity: nodeOpacity(n) }),
1044
- h("text", { className: "mneme-glabel", y: nodeRadius(n) + 13 }, n.name)
1045
- ))
1046
- )
978
+ const detail = h("div", { className: "mneme-entdetail" },
979
+ h("div", { className: "mneme-entinner" },
980
+ status === "idle" && h("div", { className: "mneme-hint" }, t("memory.entities.pick")),
981
+ status === "loading" && h("div", { className: "mneme-hint" }, t("memory.graph.loading")),
982
+ status === "notfound" && h("div", { className: "mneme-hint" }, t("memory.graph.notFound")),
983
+ status === "error" && h("div", { className: "mneme-hint" }, t("memory.panel.empty")),
984
+ status === "ready" && entity && h(react.Fragment, null,
985
+ h("div", { className: "mneme-enttitle" },
986
+ h("span", { className: "mneme-xdot", style: { color: typeColor(entity.type) }, "aria-hidden": "true" }),
987
+ entity.name
988
+ ),
989
+ h("div", { className: "mneme-entmeta" },
990
+ h("span", null, entityTypeLabel(t, entity.type || "other")),
991
+ h("span", null, t("memory.entities.mentions").replace("{n}", String(entity.mention_count ?? 1))),
992
+ h("span", { title: formatDate(entity.last_seen) },
993
+ `${t("memory.entities.lastSeen")}: ${formatRelativeTime(entity.last_seen, t)}`)
994
+ ),
995
+ // ① 属性 — current snapshot (valid_until IS NULL)
996
+ h("div", { className: "mneme-layer" },
997
+ h("div", { className: "mneme-layerhead" },
998
+ t("memory.entities.attrs"),
999
+ h("span", { className: "mneme-layercount" }, String(attrs.length))
1047
1000
  ),
1048
- h("div", { className: "mneme-graphhint" }, t("memory.graph.hint"))
1049
- )),
1050
- side
1001
+ attrs.length === 0
1002
+ ? h("div", { className: "mneme-xempty" }, t("memory.settings.empty"))
1003
+ : h("div", { className: "mneme-attrgrid" },
1004
+ attrs.map((a, i) => h("div", { key: i, className: "mneme-attrcard", title: a.value },
1005
+ h("div", { className: "mneme-attrkey" }, a.key),
1006
+ h("div", { className: "mneme-attrval" }, a.value)
1007
+ ))
1008
+ )
1009
+ ),
1010
+ // ② 关系 — both directions from the 1-hop ego walk
1011
+ h("div", { className: "mneme-layer" },
1012
+ h("div", { className: "mneme-layerhead" },
1013
+ t("memory.entities.relations"),
1014
+ h("span", { className: "mneme-layercount" }, String(rootEdges.length))
1015
+ ),
1016
+ rootEdges.length === 0
1017
+ ? h("div", { className: "mneme-xempty" }, t("memory.settings.empty"))
1018
+ : rootEdges.map((e) => {
1019
+ const out = e.from === data.root.id;
1020
+ const other = nodeById.get(out ? e.to : e.from);
1021
+ const otherName = other?.name ?? (out ? e.to : e.from);
1022
+ return h("button", {
1023
+ key: e.id,
1024
+ className: "mneme-relrow",
1025
+ title: e.memory_id ? t("memory.graph.sourceMemory") : undefined,
1026
+ onClick: () => { if (e.memory_id && onJumpMemory) onJumpMemory({ id: e.memory_id }); }
1027
+ },
1028
+ h("span", { className: "mneme-relname", style: { textAlign: "right", flex: 1 } }, entity.name),
1029
+ h("span", { className: "mneme-reltype" }, e.relation_type),
1030
+ h("span", { className: "mneme-relname", style: { flex: 1 } }, otherName)
1031
+ );
1032
+ })
1033
+ ),
1034
+ // ③ 关联记忆 — entity: prefix recall
1035
+ related.length > 0 && h("div", { className: "mneme-layer" },
1036
+ h("div", { className: "mneme-layerhead" },
1037
+ t("memory.graph.related"),
1038
+ h("span", { className: "mneme-layercount" }, String(related.length))
1039
+ ),
1040
+ related.map((m) => h("button", {
1041
+ key: m.id,
1042
+ type: "button",
1043
+ className: "mneme-gs-link",
1044
+ title: t("memory.card.open"),
1045
+ onClick: () => onJumpMemory && onJumpMemory(m)
1046
+ }, m.title || m.content?.slice(0, 60)))
1047
+ ),
1048
+ // ④ 关系图谱 — the ego graph
1049
+ h("div", { className: "mneme-layer" },
1050
+ h("div", { className: "mneme-layerhead" },
1051
+ t("memory.entities.graph"),
1052
+ h("span", { className: "mneme-layercount" },
1053
+ h("button", {
1054
+ className: depth === 2 ? "mneme-chip mneme-active" : "mneme-chip",
1055
+ title: t("memory.graph.depth"),
1056
+ onClick: () => setDepth(depth === 1 ? 2 : 1)
1057
+ }, `${depth} ${t("memory.graph.depth")}`)),
1058
+ h("span", { className: "mneme-layercount" },
1059
+ h("button", {
1060
+ className: "mneme-chip",
1061
+ title: t("memory.graph.resetView"),
1062
+ onClick: resetView
1063
+ }, t("memory.graph.resetView")))
1064
+ ),
1065
+ graphStatus === "empty"
1066
+ ? h("div", { className: "mneme-hint" }, t("memory.graph.empty"))
1067
+ : h(react.Fragment, null,
1068
+ h("svg", {
1069
+ ref: svgRef,
1070
+ className: "mneme-graphsvg",
1071
+ viewBox: `0 0 ${VIEW_W} ${VIEW_H}`,
1072
+ style: { height: 320 },
1073
+ onMouseDown: onSurfaceMouseDown
1074
+ },
1075
+ h("g", {
1076
+ transform: `translate(${viewRef.current.x},${viewRef.current.y}) scale(${viewRef.current.k})`
1077
+ },
1078
+ data.edges.map((e) => {
1079
+ const a = nodeById.get(e.from), b = nodeById.get(e.to);
1080
+ if (!a || !b) return null;
1081
+ return h("line", {
1082
+ key: e.id,
1083
+ x1: a.x, y1: a.y, x2: b.x, y2: b.y,
1084
+ className: e.memory_id ? "mneme-gedge" : "mneme-gedge mneme-gedge-dashed",
1085
+ onClick: () => onEdgeClick(e)
1086
+ });
1087
+ }),
1088
+ nodes.map((n) => h("g", {
1089
+ key: n.id,
1090
+ className: n.id === data.root.id ? "mneme-gnode mneme-groot" : "mneme-gnode",
1091
+ transform: `translate(${n.x},${n.y})`,
1092
+ onMouseDown: (e) => onNodeMouseDown(e, n),
1093
+ onClick: () => onNodeClick(n),
1094
+ "data-node": n.name
1095
+ },
1096
+ h("circle", { r: nodeRadius(n), fill: typeColor(n.type) }),
1097
+ h("text", { className: "mneme-glabel", y: nodeRadius(n) + 13 }, n.name)
1098
+ ))
1099
+ )
1100
+ ),
1101
+ h("div", { className: "mneme-graphhint" }, t("memory.graph.hint"))
1102
+ ),
1103
+ side
1104
+ )
1105
+ )
1106
+ )
1051
1107
  );
1108
+
1109
+ return h("div", { className: "mneme-ent" }, rail, detail);
1052
1110
  }
1053
1111
 
1054
- // --- Settings view: user profile, rules, custom commands, vector, token ---
1055
- const styles = {
1056
- footerButton: { padding: "4px 10px", borderRadius: 8, border: "1px solid var(--dsw-alias-border-l2, #ddd)", background: "none", cursor: "pointer", fontSize: 12, margin: "2px 8px", color: "var(--dsw-alias-label-secondary, #666)", fontFamily: "inherit" }
1057
- };
1058
1112
 
1059
1113
  function SettingsContent({ t }) {
1060
1114
  const [profile, setProfile] = react.useState("");
@@ -1072,10 +1126,21 @@ window.__ModuleLoader__.load({
1072
1126
  (typeof window !== "undefined" && window.localStorage) ? window.localStorage.getItem("dsh-mneme-api-token") || "" : ""
1073
1127
  );
1074
1128
  const [apiTokenSaved, setApiTokenSaved] = react.useState(false);
1075
- const [autoTag, setAutoTag] = react.useState(false);
1076
- const [autoTagSaved, setAutoTagSaved] = react.useState(false);
1077
- const [showSidebarTrigger, setShowSidebarTrigger] = react.useState(true);
1078
- const [sidebarTriggerSaved, setSidebarTriggerSaved] = react.useState(false);
1129
+ // 运行模式 light vs standard; null = still loading. The card keeps
1130
+ // its own busy/saved/error state so it never blocks the others.
1131
+ const [mode, setMode] = react.useState(null);
1132
+ const [modeBusy, setModeBusy] = react.useState(false);
1133
+ const [modeSaved, setModeSaved] = react.useState(false);
1134
+ const [modeError, setModeError] = react.useState("");
1135
+ // 外部访问 API — config fetched from the backend (the token is generated
1136
+ // and kept server-side); host/port inputs are drafts, PUT only on save.
1137
+ const [extapi, setExtapi] = react.useState(null);
1138
+ const [extapiHost, setExtapiHost] = react.useState("127.0.0.1");
1139
+ const [extapiPort, setExtapiPort] = react.useState("");
1140
+ const [extapiBusy, setExtapiBusy] = react.useState(false);
1141
+ const [extapiSaved, setExtapiSaved] = react.useState(false);
1142
+ const [extapiCopied, setExtapiCopied] = react.useState(false);
1143
+ const [extapiError, setExtapiError] = react.useState("");
1079
1144
 
1080
1145
  const load = react.useCallback(async () => {
1081
1146
  try {
@@ -1089,18 +1154,39 @@ window.__ModuleLoader__.load({
1089
1154
  setRules(Array.isArray(r.rules) ? r.rules : []);
1090
1155
  setCommands(Array.isArray(c.commands) ? c.commands : []);
1091
1156
  setVector(v.config || { enabled: false, baseUrl: "", apiKey: "", model: "" });
1092
- // autoTag loads independently so a config failure can't cascade-block the rest.
1093
- try {
1094
- const g = await apiFetch("/api/dsh-mneme/config").then((res) => res.json());
1095
- setAutoTag(g.config?.autoTagEnabled === true);
1096
- // issue #38: sidebar trigger defaults to visible (true).
1097
- setShowSidebarTrigger(g.config?.showSidebarTrigger !== false);
1098
- } catch { /* keep defaults */ }
1099
1157
  } catch { /* ignore */ }
1100
1158
  }, []);
1101
1159
 
1102
1160
  react.useEffect(() => { load(); }, [load]);
1103
1161
 
1162
+ // Runtime mode + external API — two independent fetches: one failing
1163
+ // endpoint only errors its own card, never the other one.
1164
+ react.useEffect(() => {
1165
+ let cancelled = false;
1166
+ const toErr = (err) => (err && err.message) || "failed";
1167
+ apiFetch("/api/dsh-mneme/mode")
1168
+ .then((res) => { if (!res.ok) throw new Error("HTTP " + res.status); return res.json(); })
1169
+ .then((j) => { if (!cancelled) setMode(j.mode === "light" ? "light" : "standard"); })
1170
+ .catch((err) => { if (!cancelled) setModeError(toErr(err)); });
1171
+ apiFetch("/api/dsh-mneme/external-api")
1172
+ .then((res) => { if (!res.ok) throw new Error("HTTP " + res.status); return res.json(); })
1173
+ .then((j) => {
1174
+ if (cancelled) return;
1175
+ const cfg = (j && j.config) || {};
1176
+ const next = {
1177
+ enabled: !!cfg.enabled,
1178
+ host: cfg.host || "127.0.0.1",
1179
+ port: Number(cfg.port) || 0,
1180
+ token: cfg.token || ""
1181
+ };
1182
+ setExtapi(next);
1183
+ setExtapiHost(next.host);
1184
+ setExtapiPort(next.port ? String(next.port) : "");
1185
+ })
1186
+ .catch((err) => { if (!cancelled) setExtapiError(toErr(err)); });
1187
+ return () => { cancelled = true; };
1188
+ }, []);
1189
+
1104
1190
  async function saveProfile() {
1105
1191
  try {
1106
1192
  await apiFetch("/api/dsh-mneme/profile", {
@@ -1192,31 +1278,87 @@ window.__ModuleLoader__.load({
1192
1278
  } catch { /* ignore */ }
1193
1279
  }
1194
1280
 
1195
- async function saveAutoTag() {
1281
+ // Runtime mode: optimistic chip flip, rolled back on failure. Both
1282
+ // changes only take effect after a DSH restart — the saved hint says so.
1283
+ async function saveMode(next) {
1284
+ if (modeBusy) return;
1285
+ const prev = mode;
1286
+ setModeBusy(true);
1287
+ setModeError("");
1288
+ setMode(next);
1196
1289
  try {
1197
- await apiFetch("/api/dsh-mneme/config", {
1290
+ const res = await apiFetch("/api/dsh-mneme/mode", {
1198
1291
  method: "PUT",
1199
1292
  headers: { "Content-Type": "application/json" },
1200
- body: JSON.stringify({ autoTagEnabled: autoTag })
1293
+ body: JSON.stringify({ mode: next })
1201
1294
  });
1202
- setAutoTagSaved(true);
1203
- setTimeout(() => setAutoTagSaved(false), 1500);
1204
- } catch { /* ignore */ }
1295
+ if (!res.ok) throw new Error("HTTP " + res.status);
1296
+ setModeSaved(true);
1297
+ setTimeout(() => setModeSaved(false), 2500);
1298
+ } catch (err) {
1299
+ setMode(prev);
1300
+ setModeError((err && err.message) || "failed");
1301
+ }
1302
+ setModeBusy(false);
1205
1303
  }
1206
1304
 
1207
- async function saveSidebarTrigger() {
1305
+ // External API: the backend owns the token and returns the full config,
1306
+ // so every PUT response refreshes host/port/token from the server.
1307
+ async function putExtapi(body) {
1308
+ setExtapiBusy(true);
1309
+ setExtapiError("");
1208
1310
  try {
1209
- await apiFetch("/api/dsh-mneme/config", {
1311
+ const res = await apiFetch("/api/dsh-mneme/external-api", {
1210
1312
  method: "PUT",
1211
1313
  headers: { "Content-Type": "application/json" },
1212
- body: JSON.stringify({ showSidebarTrigger })
1314
+ body: JSON.stringify(body)
1213
1315
  });
1214
- setSidebarTriggerSaved(true);
1215
- setTimeout(() => setSidebarTriggerSaved(false), 1500);
1216
- } catch { /* ignore */ }
1316
+ const data = await res.json().catch(() => ({}));
1317
+ if (!res.ok) throw new Error(data.error || "HTTP " + res.status);
1318
+ const cfg = data.config || {};
1319
+ const next = {
1320
+ enabled: !!cfg.enabled,
1321
+ host: cfg.host || "127.0.0.1",
1322
+ port: Number(cfg.port) || 0,
1323
+ token: cfg.token || ""
1324
+ };
1325
+ setExtapi(next);
1326
+ setExtapiHost(next.host);
1327
+ setExtapiPort(next.port ? String(next.port) : "");
1328
+ setExtapiSaved(true);
1329
+ setTimeout(() => setExtapiSaved(false), 2500);
1330
+ } catch (err) {
1331
+ setExtapiError((err && err.message) || "failed");
1332
+ }
1333
+ setExtapiBusy(false);
1334
+ }
1335
+
1336
+ function saveExtapiEnabled(enabled) {
1337
+ if (extapiBusy) return;
1338
+ putExtapi({ enabled });
1339
+ }
1340
+
1341
+ function saveExtapiAddress() {
1342
+ if (extapiBusy) return;
1343
+ const raw = String(extapiPort).trim();
1344
+ const port = Number(raw);
1345
+ if (!/^\d+$/.test(raw) || port < 1 || port > 65535) {
1346
+ setExtapiError(t("memory.settings.extapi.invalidPort"));
1347
+ return;
1348
+ }
1349
+ putExtapi({ port, host: extapiHost.trim() || "127.0.0.1" });
1350
+ }
1351
+
1352
+ function copyExtapiToken() {
1353
+ const token = extapi ? extapi.token || "" : "";
1354
+ navigator.clipboard?.writeText(token).then(
1355
+ () => { setExtapiCopied(true); setTimeout(() => setExtapiCopied(false), 1500); },
1356
+ () => {}
1357
+ );
1217
1358
  }
1218
1359
 
1219
1360
  return h("div", null,
1361
+ // 用户画像 — who the agent is talking to
1220
1362
  h("section", { className: "mneme-set-sec" },
1221
1363
  h("div", { className: "mneme-set-title" }, t("memory.settings.profile")),
1222
1364
  h("div", { className: "mneme-set-desc" }, t("memory.settings.profileHint")),
@@ -1231,6 +1373,7 @@ window.__ModuleLoader__.load({
1231
1373
  saved && h("span", { className: "mneme-saved" }, t("memory.settings.profileSaved"))
1232
1374
  )
1233
1375
  ),
1376
+ // 规则 — numbered rows, hover reveals the delete affordance
1234
1377
  h("section", { className: "mneme-set-sec" },
1235
1378
  h("div", { className: "mneme-set-title" }, t("memory.settings.rules")),
1236
1379
  h("div", { className: "mneme-set-desc" }, t("memory.settings.rulesHint")),
@@ -1259,6 +1402,7 @@ window.__ModuleLoader__.load({
1259
1402
  h("button", { className: "mneme-btn", onClick: addRule }, t("memory.settings.ruleAdd"))
1260
1403
  )
1261
1404
  ),
1405
+ // 自定义指令 — slash commands as titled rows
1262
1406
  h("section", { className: "mneme-set-sec" },
1263
1407
  h("div", { className: "mneme-set-title" }, t("memory.settings.commands")),
1264
1408
  h("div", { className: "mneme-set-desc" }, t("memory.settings.commandsHint")),
@@ -1289,30 +1433,87 @@ window.__ModuleLoader__.load({
1289
1433
  )
1290
1434
  )
1291
1435
  ),
1292
- h("section", { className: "mneme-set-sec" },
1293
- h("div", { className: "mneme-set-title" }, t("memory.settings.autoTagTitle")),
1294
- h("div", { className: "mneme-set-desc" }, t("memory.settings.autoTagHint")),
1295
- h("label", { style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 10, fontSize: 13, color: "var(--dsw-alias-label-primary)" } },
1296
- h("input", { type: "checkbox", checked: autoTag, onChange: (e) => setAutoTag(e.target.checked) }),
1297
- h("span", null, t("memory.settings.autoTagEnabled"))
1298
- ),
1299
- h("div", { style: { display: "flex", alignItems: "center", gap: 8 } },
1300
- h("button", { className: "mneme-btn", onClick: saveAutoTag }, t("memory.settings.autoTagSave")),
1301
- autoTagSaved && h("span", { className: "mneme-saved" }, t("memory.settings.autoTagSaved"))
1302
- )
1436
+ // 运行模式 light vs standard chip radios; each click PUTs and the
1437
+ // change only lands after a DSH restart (green saved hint says so).
1438
+ h("section", { className: "mneme-set-card" },
1439
+ h("div", { className: "mneme-set-title" }, t("memory.settings.mode.title")),
1440
+ h("div", { className: "mneme-set-desc" }, t("memory.settings.mode.desc")),
1441
+ modeError && h("div", { style: { fontSize: 12, color: "var(--dsw-alias-state-error,#c33)", marginBottom: 8 } }, modeError),
1442
+ mode === null && !modeError
1443
+ ? h("div", { className: "mneme-set-hint" }, "")
1444
+ : h(react.Fragment, null,
1445
+ h("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" } },
1446
+ h("button", {
1447
+ className: mode === "light" ? "mneme-chip mneme-active" : "mneme-chip",
1448
+ disabled: modeBusy,
1449
+ onClick: () => saveMode("light")
1450
+ }, t("memory.settings.mode.light")),
1451
+ h("button", {
1452
+ className: mode === "standard" ? "mneme-chip mneme-active" : "mneme-chip",
1453
+ disabled: modeBusy,
1454
+ onClick: () => saveMode("standard")
1455
+ }, t("memory.settings.mode.standard")),
1456
+ modeSaved && h("span", { className: "mneme-saved" }, t("memory.settings.mode.savedHint"))
1457
+ ),
1458
+ mode === "light" && h("div", { className: "mneme-set-hint", style: { marginTop: 8 } },
1459
+ t("memory.settings.mode.offList"))
1460
+ )
1303
1461
  ),
1304
- h("section", { className: "mneme-set-sec" },
1305
- h("div", { className: "mneme-set-title" }, t("memory.settings.sidebarTriggerTitle")),
1306
- h("div", { className: "mneme-set-desc" }, t("memory.settings.sidebarTriggerHint")),
1307
- h("label", { style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 10, fontSize: 13, color: "var(--dsw-alias-label-primary)" } },
1308
- h("input", { type: "checkbox", checked: showSidebarTrigger, onChange: (e) => setShowSidebarTrigger(e.target.checked) }),
1309
- h("span", null, t("memory.settings.sidebarTriggerEnabled"))
1310
- ),
1311
- h("div", { style: { display: "flex", alignItems: "center", gap: 8 } },
1312
- h("button", { className: "mneme-btn", onClick: saveSidebarTrigger }, t("memory.settings.sidebarTriggerSave")),
1313
- sidebarTriggerSaved && h("span", { className: "mneme-saved" }, t("memory.settings.sidebarTriggerSaved"))
1314
- )
1462
+ // 外部访问 API standalone HTTP service for plugins/CLI/desktop tools;
1463
+ // the token is generated and kept by the backend, so it is read-only
1464
+ // here with a copy affordance. Changes need a DSH restart.
1465
+ h("section", { className: "mneme-set-card" },
1466
+ h("div", { className: "mneme-set-title" }, t("memory.settings.extapi.title")),
1467
+ h("div", { className: "mneme-set-desc" }, t("memory.settings.extapi.desc")),
1468
+ extapiError && h("div", { style: { fontSize: 12, color: "var(--dsw-alias-state-error,#c33)", marginBottom: 8 } }, extapiError),
1469
+ extapi === null && !extapiError
1470
+ ? h("div", { className: "mneme-set-hint" }, "")
1471
+ : h(react.Fragment, null,
1472
+ h("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" } },
1473
+ h("button", {
1474
+ className: extapi && extapi.enabled ? "mneme-chip mneme-active" : "mneme-chip",
1475
+ disabled: extapiBusy,
1476
+ onClick: () => saveExtapiEnabled(true)
1477
+ }, t("memory.settings.extapi.enabled")),
1478
+ h("button", {
1479
+ className: extapi && !extapi.enabled ? "mneme-chip mneme-active" : "mneme-chip",
1480
+ disabled: extapiBusy,
1481
+ onClick: () => saveExtapiEnabled(false)
1482
+ }, t("memory.settings.extapi.disabled")),
1483
+ extapiSaved && h("span", { className: "mneme-saved" }, t("memory.settings.extapi.savedHint"))
1484
+ ),
1485
+ extapi && extapi.enabled && h(react.Fragment, null,
1486
+ h("div", { className: "mneme-set-hint", style: { marginTop: 10 } },
1487
+ `${t("memory.settings.extapi.address")}: http://${extapi.host}:${extapi.port}`),
1488
+ h("div", { style: { display: "flex", gap: 8, marginTop: 8 } },
1489
+ h("input", {
1490
+ className: "mneme-set-input",
1491
+ style: { marginBottom: 0, flex: 2, minWidth: 0, width: "auto" },
1492
+ value: extapiHost,
1493
+ placeholder: "127.0.0.1",
1494
+ onChange: (e) => setExtapiHost(e.target.value)
1495
+ }),
1496
+ h("input", {
1497
+ className: "mneme-set-input",
1498
+ style: { marginBottom: 0, flex: 1, minWidth: 0, width: "auto" },
1499
+ value: extapiPort,
1500
+ placeholder: t("memory.settings.extapi.port"),
1501
+ inputMode: "numeric",
1502
+ onChange: (e) => setExtapiPort(e.target.value)
1503
+ }),
1504
+ h("button", { className: "mneme-btn", disabled: extapiBusy, onClick: saveExtapiAddress },
1505
+ t("memory.settings.vectorSave"))
1506
+ ),
1507
+ h("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 10 } },
1508
+ h("div", { className: "mneme-set-token", style: { flex: 1, minWidth: 0 } }, extapi.token || "—"),
1509
+ h("button", { className: "mneme-btn", onClick: copyExtapiToken },
1510
+ t("memory.settings.extapi.copy")),
1511
+ extapiCopied && h("span", { className: "mneme-saved" }, t("memory.settings.extapi.copied"))
1512
+ )
1513
+ )
1514
+ )
1315
1515
  ),
1516
+ // 向量搜索 — semantic recall over an embeddings API
1316
1517
  h("section", { className: "mneme-set-sec" },
1317
1518
  h("div", { className: "mneme-set-title" }, t("memory.settings.vectorTitle")),
1318
1519
  h("div", { className: "mneme-set-desc" }, t("memory.settings.vectorHint")),
@@ -1330,6 +1531,7 @@ window.__ModuleLoader__.load({
1330
1531
  reindexMsg && h("span", { style: { fontSize: 12, color: "var(--dsw-alias-label-secondary, #666)" } }, reindexMsg)
1331
1532
  )
1332
1533
  ),
1534
+ // API Token — advanced, last
1333
1535
  h("section", { className: "mneme-set-sec" },
1334
1536
  h("div", { className: "mneme-set-title" }, t("memory.settings.apiTokenTitle")),
1335
1537
  h("div", { className: "mneme-set-desc" }, t("memory.settings.apiTokenHint")),
@@ -1377,9 +1579,6 @@ window.__ModuleLoader__.load({
1377
1579
  return matches;
1378
1580
  }
1379
1581
 
1380
- // Click the memory library tab and wait (bounded, rAF-polled) until the
1381
- // host actually marks it selected — a silent React re-render gap must not
1382
- // be mistaken for success, or the fallback would never kick in.
1383
1582
  // Click the memory library tab and wait (bounded, rAF-polled) until the
1384
1583
  // host marks it selected AND our explorer actually rendered (`.mneme-x`).
1385
1584
  // aria-selected alone cannot tell our tab from a same-labelled tab of
@@ -1454,314 +1653,179 @@ window.__ModuleLoader__.load({
1454
1653
  return tree;
1455
1654
  }
1456
1655
 
1457
- // --- directory sub-view (v0.6.3) ---
1458
- // A tag folder tree over the live memory set. One-level folders keyed by
1459
- // tag (handled on the server, getDirectory), each with a 二级 memory
1460
- // entry list (title + updated time); memories without a live tag land in
1461
- // the "untagged" folder. Collapse state lives in MemoryExplorer (lifted via
1462
- // `collapsed`), so switching tabs does not drop it. Clicking an entry jumps
1463
- // back into the browser with that memory selected (onJump = jumpToMemory).
1464
- function DirectoryPanel({ t, onJump, collapsed, setCollapsed }) {
1465
- const [dir, setDir] = useState(null); // { groups: [...], untagged: [...] }
1466
- const [status, setStatus] = useState("loading"); // loading | ready | error
1467
- const [editMode, setEditMode] = useState(false); // manual tag/delete UI gate
1468
- const [confirmingId, setConfirmingId] = useState(null); // row waiting for the 2nd confirm click
1469
- const [delErrorId, setDelErrorId] = useState(null); // row whose delete just failed
1470
- const confirmTimer = useRef(null);
1656
+ const EXPLORER_TYPES = ["preference", "project", "decision", "summary", "history"];
1657
+ const PAGE_SIZE = 100; // browse page size the tree grows by 100-row pages
1658
+
1659
+ // --- Status sub-view: a responsive grid of stat cards. Every card owns
1660
+ // its fetch, loading ("") and error state, so one failing endpoint
1661
+ // never blanks or blocks the others. ---
1662
+ function StatusCard({ t, title, loading, error, num, cap }) {
1663
+ return h("div", { className: "mneme-statuscard" },
1664
+ h("div", { className: "mneme-xcolhead" }, title),
1665
+ loading
1666
+ ? h("div", { className: "mneme-statusnum" }, "…")
1667
+ : error
1668
+ ? h("div", { className: "mneme-statuscap", style: { color: "var(--dsw-alias-state-error,#c33)" } }, t("memory.status.error"))
1669
+ : h(react.Fragment, null,
1670
+ h("div", { className: "mneme-statusnum" }, num),
1671
+ cap ? h("div", { className: "mneme-statuscap" }, cap) : null
1672
+ )
1673
+ );
1674
+ }
1471
1675
 
1676
+ // 记忆总数 — list total plus per-type subtotals; each subtotal request
1677
+ // may fail independently (rendered as "—") without sinking the card.
1678
+ function MemoriesStatusCard({ t }) {
1679
+ const [state, setState] = useState({ loading: true, error: false, total: 0, byType: [] });
1472
1680
  useEffect(() => {
1473
1681
  let cancelled = false;
1474
- setStatus("loading");
1475
- apiFetch("/api/dsh-mneme/directory")
1476
- .then((res) => (res.ok ? res.json() : null))
1477
- .then((d) => {
1478
- if (cancelled) return;
1479
- if (!d) { setDir(null); setStatus("error"); return; }
1480
- setDir({ groups: Array.isArray(d.groups) ? d.groups : [], untagged: Array.isArray(d.untagged) ? d.untagged : [] });
1481
- setStatus("ready");
1682
+ const one = (ty) =>
1683
+ apiFetch(`/api/dsh-mneme/list?limit=1&order=chrono${ty ? `&type=${encodeURIComponent(ty)}` : ""}`)
1684
+ .then((res) => { if (!res.ok) throw new Error("http"); return res.json(); });
1685
+ one()
1686
+ .then((all) => {
1687
+ if (cancelled) return null;
1688
+ const total = all.total || 0;
1689
+ return Promise.all(EXPLORER_TYPES.map((ty) =>
1690
+ one(ty).then((j) => [ty, j.total || 0]).catch(() => [ty, null])
1691
+ )).then((byType) => {
1692
+ if (!cancelled) setState({ loading: false, error: false, total, byType });
1693
+ });
1482
1694
  })
1483
- .catch(() => { if (!cancelled) { setDir(null); setStatus("error"); } });
1484
- apiFetch("/api/dsh-mneme/config")
1485
- .then((res) => (res.ok ? res.json() : null))
1486
- .then((d) => { if (!cancelled && d?.config && typeof d.config.manualTagEnabled === "boolean") setEditMode(d.config.manualTagEnabled); })
1487
- .catch(() => {});
1695
+ .catch(() => { if (!cancelled) setState({ loading: false, error: true, total: 0, byType: [] }); });
1488
1696
  return () => { cancelled = true; };
1489
1697
  }, []);
1698
+ const cap = state.byType
1699
+ .map(([ty, n]) => `${typeLabel(t, ty)} ${n === null ? "—" : n}`)
1700
+ .join(" · ");
1701
+ return h(StatusCard, {
1702
+ t,
1703
+ title: t("memory.status.memories"),
1704
+ loading: state.loading,
1705
+ error: state.error,
1706
+ num: state.total.toLocaleString(),
1707
+ cap
1708
+ });
1709
+ }
1490
1710
 
1491
- const toggle = (key) => setCollapsed((c) => ({ ...c, [key]: !c[key] }));
1492
-
1493
- const toggleEdit = () => {
1494
- const next = !editMode;
1495
- apiFetch("/api/dsh-mneme/config", {
1496
- method: "PUT",
1497
- headers: { "Content-Type": "application/json" },
1498
- body: JSON.stringify({ manualTagEnabled: next })
1499
- }).then((res) => { if (res.ok) setEditMode(next); }).catch(() => {});
1500
- };
1501
-
1502
- const resetConfirm = () => {
1503
- clearTimeout(confirmTimer.current);
1504
- setConfirmingId(null);
1505
- };
1506
-
1507
- const handleDelete = (memId, e) => {
1508
- e?.stopPropagation?.();
1509
- // 第一击进入「确认?」态,第二击才真正发 DELETE;宿主 web 环境里
1510
- // window.confirm 不可靠(可能被拦截或直接返回 false),点击会无反应,
1511
- // 所以改为面板内联两步确认,不依赖任何原生对话框。
1512
- if (confirmingId !== memId) {
1513
- setConfirmingId(memId);
1514
- clearTimeout(confirmTimer.current);
1515
- confirmTimer.current = setTimeout(() => setConfirmingId((cur) => (cur === memId ? null : cur)), 3000);
1516
- return;
1517
- }
1518
- clearTimeout(confirmTimer.current);
1519
- setConfirmingId(null);
1520
- apiFetch("/api/dsh-mneme/memories", {
1521
- method: "DELETE",
1522
- headers: { "Content-Type": "application/json" },
1523
- body: JSON.stringify({ id: memId })
1524
- })
1525
- .then((res) => (res.ok ? res.json() : null))
1526
- .then((d) => {
1527
- if (!d || d.deleted !== true) {
1528
- // 服务端未确认删除也给出可见提示,不静默吞掉
1529
- setDelErrorId(memId);
1530
- setTimeout(() => setDelErrorId((cur) => (cur === memId ? null : cur)), 3500);
1531
- return;
1711
+ // 实体 directory snapshot grouped by entity type.
1712
+ function EntitiesStatusCard({ t }) {
1713
+ const [state, setState] = useState({ loading: true, error: false, total: 0, byType: [] });
1714
+ useEffect(() => {
1715
+ let cancelled = false;
1716
+ const order = ["organization", "person", "project", "technology", "concept"];
1717
+ apiFetch("/api/dsh-mneme/entities?limit=500")
1718
+ .then((res) => { if (!res.ok) throw new Error("http"); return res.json(); })
1719
+ .then((j) => {
1720
+ if (cancelled) return;
1721
+ const list = Array.isArray(j.entities) ? j.entities : [];
1722
+ const counts = new Map();
1723
+ for (const e of list) {
1724
+ const key = order.includes(e.type) ? e.type : "other";
1725
+ counts.set(key, (counts.get(key) || 0) + 1);
1532
1726
  }
1533
- setDir((prev) => ({
1534
- groups: prev.groups.map((g) => ({ ...g, memories: g.memories.filter((m) => m.id !== memId) })),
1535
- untagged: prev.untagged.filter((m) => m.id !== memId)
1536
- }));
1727
+ const byType = order.concat("other")
1728
+ .filter((k) => counts.has(k))
1729
+ .map((k) => [k, counts.get(k)]);
1730
+ setState({ loading: false, error: false, total: list.length, byType });
1537
1731
  })
1538
- .catch(() => {
1539
- // 网络失败同样给出可见提示
1540
- setDelErrorId(memId);
1541
- setTimeout(() => setDelErrorId((cur) => (cur === memId ? null : cur)), 3500);
1542
- });
1543
- };
1544
-
1545
- const renderFolder = (name, memories, extraKey, emptyLabel) => {
1546
- const folderKey = extraKey || name;
1547
- const open = !collapsed[folderKey];
1548
- const rows = memories.map((m) => {
1549
- const confirming = confirmingId === m.id;
1550
- const err = delErrorId === m.id;
1551
- return h("div", {
1552
- key: m.id,
1553
- className: err ? "mneme-dir-item mneme-dir-error" : "mneme-dir-item",
1554
- // 点击行内其它地方取消「确认?」态
1555
- onClick: resetConfirm
1556
- },
1557
- h("span", { className: "mneme-tree-line" }),
1558
- h("button", { type: "button", className: "mneme-dir-ititle", onClick: () => onJump(m) }, m.title || m.content?.slice(0, 40)),
1559
- h("span", { className: "mneme-dir-itime" }, formatDate(m.updated_at || m.created_at)),
1560
- editMode && h("button", {
1561
- type: "button",
1562
- className: confirming ? "mneme-dir-del mneme-dir-del-confirm" : "mneme-dir-del",
1563
- "aria-label": "delete",
1564
- onClick: (e) => handleDelete(m.id, e)
1565
- }, confirming ? t("directory.deleteConfirmShort") : "✕"),
1566
- err && h("span", { className: "mneme-dir-del-err" }, t("directory.deleteError"))
1567
- );
1568
- });
1569
- return h("div", {
1570
- key: folderKey,
1571
- className: "mneme-dir-folder",
1572
- "data-expanded": String(open)
1573
- },
1574
- h("button", {
1575
- type: "button",
1576
- className: "mneme-dir-folderhead",
1577
- "aria-expanded": String(open),
1578
- onClick: () => toggle(folderKey)
1579
- },
1580
- h("span", { className: "mneme-dir-caret" }, open ? "▾" : "▸"),
1581
- h("span", { className: "mneme-dir-fname" }, `📁 ${name}`),
1582
- h("span", { className: "mneme-dir-fcount" }, String(memories.length))
1583
- ),
1584
- open && h("div", { className: "mneme-dir-fbody" },
1585
- rows.length > 0 ? rows : h("div", { className: "mneme-dir-empty" }, emptyLabel || t("memory.directory.empty"))
1586
- )
1587
- );
1588
- };
1589
-
1590
- if (status === "loading") {
1591
- return h("div", { className: "mneme-directory" }, h("div", { className: "mneme-dir-empty" }, t("memory.directory.loading")));
1592
- }
1593
- const hasAny = dir && (dir.groups.length > 0 || dir.untagged.length > 0);
1594
- if (status === "error" || !hasAny) {
1595
- return h("div", { className: "mneme-directory" }, h("div", { className: "mneme-dir-empty" }, t("memory.directory.empty")));
1596
- }
1597
- return h("div", { className: editMode ? "mneme-directory mneme-edit-on" : "mneme-directory" },
1598
- h("label", { className: "mneme-edit-toggle" },
1599
- h("input", { type: "checkbox", checked: editMode, onChange: toggleEdit }),
1600
- t("directory.editToggle")
1601
- ),
1602
- dir.groups.map((g) => renderFolder(g.tag, g.memories)),
1603
- dir.untagged.length > 0 && renderFolder(t("memory.directory.untagged"), dir.untagged, "mneme-untagged")
1604
- );
1732
+ .catch(() => { if (!cancelled) setState({ loading: false, error: true, total: 0, byType: [] }); });
1733
+ return () => { cancelled = true; };
1734
+ }, []);
1735
+ const cap = state.byType.map(([ty, n]) => `${entityTypeLabel(t, ty)} ${n}`).join(" · ");
1736
+ return h(StatusCard, {
1737
+ t,
1738
+ title: t("memory.status.entities"),
1739
+ loading: state.loading,
1740
+ error: state.error,
1741
+ num: state.total.toLocaleString(),
1742
+ cap
1743
+ });
1605
1744
  }
1606
1745
 
1607
- // --- Overview sub-view (layered stats dashboard) ---
1608
- // Card grid of every memory type (fixed display order, strays appended
1609
- // alphabetically), plus a user-profile card, a CSS-only type distribution
1610
- // bar chart and a 7-day creation trend. Data comes from /stats; each tile
1611
- // probes /list?type=X&limit=1 for a representative latest title (the list
1612
- // API sorts importance-first, so limit=1 is the type's most prominent row).
1613
- const OVERVIEW_TYPE_ORDER = ["preference", "project", "decision", "history", "user", "fact", "summary", "pattern"];
1614
- // Injection-priority tiers from service.js (summary > preference/user >
1615
- // importance): these layers ride into nearly every prompt.
1616
- const OVERVIEW_INJECT_TYPES = ["summary", "user", "preference"];
1617
-
1618
- function OverviewPanel({ t, onPickType }) {
1619
- const [stats, setStats] = useState(null); // null = loading
1620
- const [profile, setProfile] = useState(null); // user-type memories (list card)
1621
- const [latest, setLatest] = useState({}); // type -> representative memory | null
1622
-
1746
+ // 向量索引 whether semantic recall is switched on.
1747
+ function VectorStatusCard({ t }) {
1748
+ const [state, setState] = useState({ loading: true, error: false, enabled: false });
1623
1749
  useEffect(() => {
1624
1750
  let cancelled = false;
1625
- apiFetch("/api/dsh-mneme/stats?days=7")
1626
- .then((r) => (r.ok ? r.json() : null))
1627
- .then((d) => { if (!cancelled) setStats(d && d.byType ? d : { byType: {}, total: 0, recent: [] }); })
1628
- .catch(() => { if (!cancelled) setStats({ byType: {}, total: 0, recent: [] }); });
1629
- apiFetch("/api/dsh-mneme/list?type=user&limit=20")
1630
- .then((r) => (r.ok ? r.json() : { items: [] }))
1631
- .then((d) => { if (!cancelled) setProfile(Array.isArray(d.items) ? d.items : []); })
1632
- .catch(() => { if (!cancelled) setProfile([]); });
1751
+ apiFetch("/api/dsh-mneme/vector-config")
1752
+ .then((res) => { if (!res.ok) throw new Error("http"); return res.json(); })
1753
+ .then((j) => { if (!cancelled) setState({ loading: false, error: false, enabled: !!j.config?.enabled }); })
1754
+ .catch(() => { if (!cancelled) setState({ loading: false, error: true, enabled: false }); });
1633
1755
  return () => { cancelled = true; };
1634
1756
  }, []);
1757
+ return h(StatusCard, {
1758
+ t,
1759
+ title: t("memory.status.vector"),
1760
+ loading: state.loading,
1761
+ error: state.error,
1762
+ num: state.enabled ? t("memory.status.vectorOn") : t("memory.status.vectorOff"),
1763
+ cap: ""
1764
+ });
1765
+ }
1635
1766
 
1636
- const byType = stats ? stats.byType : {};
1637
- const gridTypes = stats
1638
- ? OVERVIEW_TYPE_ORDER.filter((k) => byType[k]).concat(
1639
- Object.keys(byType).filter((k) => !OVERVIEW_TYPE_ORDER.includes(k)).sort())
1640
- : [];
1641
-
1642
- // Representative-latest probe, one small request per visible layer.
1643
- // Derived purely from stats, so [stats] is the full dependency set.
1767
+ // LLM 消耗 calls + tokens over the trailing 7 days.
1768
+ function LlmStatusCard({ t }) {
1769
+ const [state, setState] = useState({ loading: true, error: false, calls: 0, tokens: 0 });
1644
1770
  useEffect(() => {
1645
- if (!stats || gridTypes.length === 0) return;
1646
1771
  let cancelled = false;
1647
- Promise.all(gridTypes.map((k) =>
1648
- apiFetch(`/api/dsh-mneme/list?type=${encodeURIComponent(k)}&limit=1`)
1649
- .then((r) => (r.ok ? r.json() : { items: [] }))
1650
- .then((d) => [k, (Array.isArray(d.items) && d.items[0]) || null])
1651
- .catch(() => [k, null])
1652
- )).then((pairs) => { if (!cancelled) setLatest(Object.fromEntries(pairs)); });
1772
+ apiFetch("/api/dsh-mneme/semantic/llm-audit/stats?days=7")
1773
+ .then((res) => { if (!res.ok) throw new Error("http"); return res.json(); })
1774
+ .then((j) => {
1775
+ if (cancelled) return;
1776
+ const s = j && typeof j === "object" ? (j.stats || j) : {};
1777
+ setState({ loading: false, error: false, calls: Number(s.total_calls ?? 0), tokens: Number(s.total_tokens ?? 0) });
1778
+ })
1779
+ .catch(() => { if (!cancelled) setState({ loading: false, error: true, calls: 0, tokens: 0 }); });
1653
1780
  return () => { cancelled = true; };
1654
- // eslint-disable-next-line react-hooks/exhaustive-deps
1655
- }, [stats]);
1656
-
1657
- if (!stats) return h("div", { className: "mneme-ov-wrap" }, h("div", { className: "mneme-hint" }, t("memory.directory.loading")));
1658
- if (stats.total === 0) return h("div", { className: "mneme-ov-wrap" }, h("div", { className: "mneme-hint" }, t("memory.overview.empty")));
1659
-
1660
- const distRow = (key) => {
1661
- const count = byType[key] || 0;
1662
- const pct = stats.total > 0 ? Math.round((count / stats.total) * 100) : 0;
1663
- return h("div", { key, className: "mneme-ov-distrow" },
1664
- h("span", { className: "mneme-ov-distlabel" }, typeLabel(t, key)),
1665
- h("span", { className: "mneme-ov-distcount" }, String(count)),
1666
- h("div", { className: "mneme-ov-disttrack" },
1667
- h("div", { className: "mneme-ov-distbar", style: { width: `${pct}%` } }))
1668
- );
1669
- };
1670
-
1671
- const tile = (key) => {
1672
- const inject = OVERVIEW_INJECT_TYPES.includes(key);
1673
- const last = latest[key];
1674
- return h("button", {
1675
- key,
1676
- type: "button",
1677
- className: "mneme-card mneme-ov-tile",
1678
- title: t("memory.card.open"),
1679
- onClick: () => onPickType && onPickType(key)
1680
- },
1681
- h("div", { className: "mneme-ov-tilehead" },
1682
- h("span", { className: "mneme-ov-tiletitle" }, typeLabel(t, key)),
1683
- h("span", { className: inject ? "mneme-ov-badge" : "mneme-ov-badge mneme-ov-badge--dim" },
1684
- inject ? t("memory.overview.badgeInject") : t("memory.overview.badgeImportance")),
1685
- h("span", { className: "mneme-ov-tilecount" }, String(byType[key] || 0))
1686
- ),
1687
- h("div", { className: "mneme-ov-tilelatest" },
1688
- (last && (last.title || (last.content || "").slice(0, 40))) || t("memory.overview.none"))
1689
- );
1690
- };
1691
-
1692
- // Trend bars scale to the busiest day; heights are fixed px so they do
1693
- // not depend on percentage resolution inside the flex columns.
1694
- const recent = Array.isArray(stats.recent) ? stats.recent : [];
1695
- const maxTrend = Math.max(1, ...recent.map((d) => d.count || 0));
1696
- const trendCol = (d) => {
1697
- const count = d.count || 0;
1698
- const px = count > 0 ? 6 + Math.round((count / maxTrend) * 90) : 0;
1699
- return h("div", {
1700
- key: d.date,
1701
- className: "mneme-ov-trendcol",
1702
- title: `${d.date} · ${t("memory.explorer.count").replace("{n}", String(count))}`
1703
- },
1704
- h("div", { className: "mneme-ov-trendbar", style: { height: `${px}px` } }),
1705
- h("div", { className: "mneme-ov-trenddate" }, (d.date || "").slice(5)) // MM-DD
1706
- );
1707
- };
1781
+ }, []);
1782
+ return h(StatusCard, {
1783
+ t,
1784
+ title: t("memory.status.llm"),
1785
+ loading: state.loading,
1786
+ error: state.error,
1787
+ num: state.tokens.toLocaleString(),
1788
+ cap: t("memory.status.llmCalls").replace("{n}", state.calls.toLocaleString())
1789
+ });
1790
+ }
1708
1791
 
1709
- return h("div", { className: "mneme-ov-wrap" },
1710
- h("div", { className: "mneme-card mneme-ov-profile", role: "region", "aria-label": t("memory.settings.profile") },
1711
- h("div", { className: "mneme-xcolhead" }, t("memory.settings.profile")),
1712
- profile === null
1713
- ? h("div", { className: "mneme-ov-ppreview" }, t("memory.directory.loading"))
1714
- : profile.length === 0
1715
- ? h("div", { className: "mneme-ov-ppreview" }, t("memory.overview.profileEmpty"))
1716
- : profile.map((m) => h("div", { key: m.id, className: "mneme-ov-prow" },
1717
- h("span", { className: "mneme-ov-ptitle" }, m.title || "—"),
1718
- h("span", { className: "mneme-ov-ppreview" }, (m.content_preview || m.content || "").slice(0, 100))
1719
- ))
1720
- ),
1721
- h("div", { className: "mneme-card", role: "region", "aria-label": t("memory.overview.distribution") },
1722
- h("div", { className: "mneme-xcolhead" }, t("memory.overview.distribution")),
1723
- h("div", { className: "mneme-ov-dist" }, gridTypes.map(distRow))
1724
- ),
1725
- h("div", { className: "mneme-xcolhead" }, t("memory.overview.layers")),
1726
- h("div", { className: "mneme-ov-grid" }, gridTypes.map(tile)),
1727
- h("div", { className: "mneme-card", role: "region", "aria-label": t("memory.overview.trend") },
1728
- h("div", { className: "mneme-xcolhead" }, t("memory.overview.trend")),
1729
- recent.length === 0
1730
- ? h("div", { className: "mneme-ov-tilelatest" }, t("memory.overview.none"))
1731
- : h("div", { className: "mneme-ov-trend" }, recent.map(trendCol))
1792
+ function StatusPanel({ t }) {
1793
+ return h("div", { className: "mneme-status" },
1794
+ h("div", { className: "mneme-statusgrid" },
1795
+ h(MemoriesStatusCard, { t }),
1796
+ h(EntitiesStatusCard, { t }),
1797
+ h(VectorStatusCard, { t }),
1798
+ h(LlmStatusCard, { t })
1732
1799
  )
1733
1800
  );
1734
1801
  }
1735
1802
 
1736
- const EXPLORER_TYPES = ["preference", "project", "decision", "summary", "history", "user", "fact"];
1737
1803
 
1738
1804
  function MemoryExplorer({ t }) {
1739
- const [view, setView] = useState("memory"); // memory | overview | directory | graph | settings
1740
- const [items, setItems] = useState([]);
1805
+ const [view, setView] = useState("memory"); // memory | entity | status | settings
1806
+ const [items, setItems] = useState([]); // loaded browse pages, chrono desc
1807
+ const [total, setTotal] = useState(0); // server count for the current type filter
1741
1808
  const [loading, setLoading] = useState(true);
1809
+ const [loadingMore, setLoadingMore] = useState(false);
1742
1810
  const [type, setType] = useState("all");
1811
+ const [minImp, setMinImp] = useState(0); // importance floor: 0 = all, else 3/4/5
1743
1812
  const [query, setQuery] = useState("");
1744
1813
  const [semantic, setSemantic] = useState(false);
1745
1814
  const [vecEnabled, setVecEnabled] = useState(false);
1746
1815
  const [searchTopK, setSearchTopK] = useState(20);
1747
1816
  const [remoteItems, setRemoteItems] = useState(null);
1748
1817
  const [selectedId, setSelectedId] = useState(null);
1818
+ const [expandedMonths, setExpandedMonths] = useState(null); // null = 仅最新一个月展开
1749
1819
  const [collapsed, setCollapsed] = useState({});
1750
- // v0.6.3 directory sub-view: its own folder-collapse state, kept in
1751
- // MemoryExplorer so switching tabs does not lose it and jumpToMemory's
1752
- // time-tree reset (setCollapsed({})) cannot clobber it.
1753
- const [dirCollapsed, setDirCollapsed] = useState({});
1754
1820
  const [copied, setCopied] = useState(false);
1755
1821
  const [reloadKey, setReloadKey] = useState(0);
1822
+ const [confirmDelete, setConfirmDelete] = useState(false); // two-step delete armed
1823
+ const [deleting, setDeleting] = useState(false);
1824
+ const [deleteError, setDeleteError] = useState(false);
1825
+ const [deleteMsg, setDeleteMsg] = useState(false); // transient "deleted" notice
1756
1826
  const [graphFocus, setGraphFocus] = useState("");
1757
- // v0.6.2 tag editing state for the detail pane. The authoritative tag set
1758
- // lives server-side (entity_attrs), so it is fetched per selected memory
1759
- // and refreshed after every write instead of trusting the list snapshot.
1760
- const [tagTags, setTagTags] = useState([]);
1761
- const [tagManual, setTagManual] = useState(true);
1762
- const [tagInput, setTagInput] = useState("");
1763
- const [tagAdding, setTagAdding] = useState(false);
1764
1827
  const itemRefs = useRef(new Map());
1828
+ const moreRef = useRef(null);
1765
1829
 
1766
1830
  useEffect(() => {
1767
1831
  apiFetch("/api/dsh-mneme/vector-config")
@@ -1770,60 +1834,112 @@ window.__ModuleLoader__.load({
1770
1834
  .catch(() => {});
1771
1835
  }, []);
1772
1836
 
1837
+ // One query string behind every browse-list fetch (initial page,
1838
+ // loadMore, auto-refresh): the type filter and the importance floor
1839
+ // always travel together, so counts and pagination stay consistent.
1840
+ const filterQS = (type === "all" ? "" : `&type=${encodeURIComponent(type)}`)
1841
+ + (minImp ? `&minImportance=${minImp}` : "");
1842
+ const listUrl = (offset) => `/api/dsh-mneme/list?limit=${PAGE_SIZE}&offset=${offset}${filterQS}&order=chrono`;
1843
+
1844
+ // Browse = paged, pure-chronological pages (order=chrono). The default
1845
+ // importance ordering would interleave months across pages and break
1846
+ // the month tree; type filters server-side so pages stay consistent
1847
+ // however large the store grows.
1773
1848
  useEffect(() => {
1774
1849
  let cancelled = false;
1775
1850
  setLoading(true);
1776
- apiFetch("/api/dsh-mneme/list?limit=500")
1777
- .then((res) => (res.ok ? res.json() : { items: [] }))
1778
- .then((d) => { if (!cancelled) { setItems(d.items || []); setLoading(false); } })
1779
- .catch(() => { if (!cancelled) { setItems([]); setLoading(false); } });
1851
+ apiFetch(listUrl(0))
1852
+ .then((res) => (res.ok ? res.json() : { items: [], total: 0 }))
1853
+ .then((d) => {
1854
+ if (cancelled) return;
1855
+ setItems(d.items || []);
1856
+ setTotal(d.total || 0);
1857
+ setExpandedMonths(null); // 新数据回到「仅最新月展开」
1858
+ setLoading(false);
1859
+ })
1860
+ .catch(() => { if (!cancelled) { setItems([]); setTotal(0); setLoading(false); } });
1780
1861
  return () => { cancelled = true; };
1781
- }, [reloadKey]);
1862
+ }, [reloadKey, filterQS]);
1863
+
1864
+ const canLoadMore = !query.trim() && !remoteItems && items.length < total;
1865
+ const loadMore = useCallback(() => {
1866
+ if (loadingMore || query.trim() || remoteItems) return;
1867
+ if (items.length >= total) return;
1868
+ setLoadingMore(true);
1869
+ apiFetch(listUrl(items.length))
1870
+ .then((res) => (res.ok ? res.json() : { items: [] }))
1871
+ .then((d) => setItems((cur) => cur.concat(d.items || [])))
1872
+ .catch(() => {})
1873
+ .finally(() => setLoadingMore(false));
1874
+ }, [items.length, total, loadingMore, query, remoteItems, filterQS]);
1782
1875
 
1783
- // Semantic search: server-side ranking replaces the client filter
1784
- // while enabled and a query is present (debounced). tag: queries always
1785
- // go server-side (searchMemories resolves them without vector support),
1786
- // so they bypass the semantic toggle and use the same search endpoint.
1876
+ // Infinite scroll: a sentinel just past the loaded rows pulls the next
1877
+ // page while browsing (search results arrive complete already).
1878
+ useEffect(() => {
1879
+ const el = moreRef.current;
1880
+ if (!el || !canLoadMore) return undefined;
1881
+ const io = new IntersectionObserver((entries) => {
1882
+ if (entries.some((e) => e.isIntersecting)) loadMore();
1883
+ }, { root: el.closest(".mneme-xtree"), rootMargin: "240px" });
1884
+ io.observe(el);
1885
+ return () => io.disconnect();
1886
+ }, [canLoadMore, loadMore]);
1887
+
1888
+ // Search hits the server so matches are global — not limited to the
1889
+ // loaded pages: keyword = literal text; vector = semantic ranking
1890
+ // (the pipeline falls back to keyword when no embedder is available).
1787
1891
  useEffect(() => {
1788
- const q = query.trim();
1789
- if ((!semantic && !q.startsWith("tag:")) || !q || q.startsWith("entity:")) { setRemoteItems(null); return; }
1892
+ const query0 = query.trim();
1893
+ if (!query0 || query0.startsWith("entity:")) { setRemoteItems(null); return; }
1790
1894
  let cancelled = false;
1791
1895
  const timer = setTimeout(() => {
1792
- apiFetch(`/api/dsh-mneme/search?q=${encodeURIComponent(q)}&mode=vector&topK=${searchTopK}`)
1896
+ const mode = semantic ? "vector" : "keyword";
1897
+ apiFetch(`/api/dsh-mneme/search?q=${encodeURIComponent(query0)}&mode=${mode}&topK=${searchTopK}`)
1793
1898
  .then((res) => (res.ok ? res.json() : { items: [] }))
1794
1899
  .then((d) => { if (!cancelled) setRemoteItems(d.items || []); })
1795
1900
  .catch(() => { if (!cancelled) setRemoteItems([]); });
1796
1901
  }, 250);
1797
1902
  return () => { cancelled = true; clearTimeout(timer); };
1798
- }, [semantic, query, searchTopK]);
1903
+ }, [query, semantic, searchTopK]);
1904
+
1905
+ // Live view: while the memory tab is open, quietly re-fetch page 1
1906
+ // every 30s (and on every activation) so fresh memories surface
1907
+ // without a manual refresh. Later pages stay as loaded.
1908
+ const refreshFirstPage = useCallback(() => {
1909
+ if (query.trim() || remoteItems) return;
1910
+ apiFetch(listUrl(0))
1911
+ .then((res) => (res.ok ? res.json() : null))
1912
+ .then((d) => {
1913
+ if (!d) return;
1914
+ setTotal(d.total || 0);
1915
+ setItems((cur) => {
1916
+ const fresh = d.items || [];
1917
+ const seen = new Set(fresh.map((m) => m.id));
1918
+ return fresh.concat(cur.filter((m) => !seen.has(m.id)));
1919
+ });
1920
+ })
1921
+ .catch(() => {});
1922
+ }, [query, remoteItems, filterQS]);
1923
+
1924
+ useEffect(() => {
1925
+ if (view !== "memory") return undefined;
1926
+ refreshFirstPage();
1927
+ const iv = setInterval(() => {
1928
+ if (document.visibilityState === "visible") refreshFirstPage();
1929
+ }, 30000);
1930
+ return () => clearInterval(iv);
1931
+ }, [view, refreshFirstPage]);
1799
1932
 
1800
1933
  useEffect(() => {
1801
1934
  if (!selectedId) return;
1802
1935
  itemRefs.current.get(selectedId)?.scrollIntoView({ block: "nearest" });
1803
1936
  }, [selectedId]);
1804
1937
 
1805
- // Load the authoritative tag set for the selected memory (entity_attrs-
1806
- // backed) plus the manualTagEnabled gate that hides editing when off.
1807
- const loadTags = (id) => {
1808
- if (!id) { setTagTags([]); setTagManual(true); setTagInput(""); return; }
1809
- apiFetch(`/api/dsh-mneme/memory/tags?id=${encodeURIComponent(id)}`)
1810
- .then((res) => (res.ok ? res.json() : null))
1811
- .then((j) => { if (j) { setTagTags(Array.isArray(j.tags) ? j.tags : []); setTagManual(j.manualTagEnabled !== false); } })
1812
- .catch(() => {});
1813
- };
1938
+ // The two-step delete arms per selection: switching or clearing the
1939
+ // target disarms it, so a stale confirm can never delete a new pick.
1814
1940
  useEffect(() => {
1815
- let cancelled = false;
1816
- if (selectedId) {
1817
- apiFetch(`/api/dsh-mneme/memory/tags?id=${encodeURIComponent(selectedId)}`)
1818
- .then((res) => (res.ok ? res.json() : null))
1819
- .then((j) => { if (!cancelled && j) { setTagTags(Array.isArray(j.tags) ? j.tags : []); setTagManual(j.manualTagEnabled !== false); } })
1820
- .catch(() => {});
1821
- } else {
1822
- setTagTags([]);
1823
- setTagManual(true);
1824
- }
1825
- setTagAdding(false);
1826
- return () => { cancelled = true; };
1941
+ setConfirmDelete(false);
1942
+ setDeleteError(false);
1827
1943
  }, [selectedId]);
1828
1944
 
1829
1945
  const q = query.trim().toLowerCase();
@@ -1833,13 +1949,12 @@ window.__ModuleLoader__.load({
1833
1949
  const entityQuery = query.trim().startsWith("entity:")
1834
1950
  ? query.trim().slice(7).trim()
1835
1951
  : "";
1952
+ // Search results were filtered server-side (global); the type rail
1953
+ // still narrows them. Browse mode serves the loaded pages as-is —
1954
+ // type filtering happened in the query.
1836
1955
  const visible = remoteItems
1837
- ? remoteItems
1838
- : items.filter((m) => {
1839
- if (type !== "all" && m.type !== type) return false;
1840
- if (!q) return true;
1841
- return (m.title || "").toLowerCase().includes(q) || (m.content || "").toLowerCase().includes(q);
1842
- });
1956
+ ? remoteItems.filter((m) => type === "all" || m.type === type)
1957
+ : items;
1843
1958
 
1844
1959
  const counts = {};
1845
1960
  for (const m of items) counts[m.type] = (counts[m.type] || 0) + 1;
@@ -1874,8 +1989,9 @@ window.__ModuleLoader__.load({
1874
1989
  }
1875
1990
  curDay.items.push(m);
1876
1991
  }
1992
+ for (const mo of months) mo.count = mo.days.reduce((s, d) => s + d.items.length, 0);
1877
1993
 
1878
- const selected = items.find((m) => m.id === selectedId) || null;
1994
+ const selected = visible.find((m) => m.id === selectedId) || null;
1879
1995
 
1880
1996
  const copyContent = () => {
1881
1997
  if (!selected) return;
@@ -1885,80 +2001,61 @@ window.__ModuleLoader__.load({
1885
2001
  );
1886
2002
  };
1887
2003
 
1888
- // v0.6.2 tag editing: a chip click jumps to a tag:xxx search (reusing
1889
- // the query-driven search flow), the + entry and per-chip × post the new
1890
- // tag set through the tags endpoint then re-fetch. A 409 from the server
1891
- // means manualTagEnabled turned offflip the gate so editing hides.
1892
- const submitTag = () => {
1893
- const tag = tagInput.trim();
1894
- if (!tag || !selectedId) return;
1895
- apiFetch("/api/dsh-mneme/memory/tags", {
1896
- method: "POST",
1897
- headers: { "Content-Type": "application/json" },
1898
- body: JSON.stringify({ id: selectedId, tags: [...tagTags, tag] })
1899
- }).then((res) => {
1900
- if (res.status === 409) { setTagManual(false); setTagAdding(false); setTagInput(""); return; }
1901
- return res.json();
1902
- }).then((j) => {
1903
- if (j?.ok) { setTagAdding(false); setTagInput(""); loadTags(selectedId); }
1904
- }).catch(() => {});
1905
- };
1906
- const removeTag = (tag) => {
1907
- if (!selectedId) return;
1908
- apiFetch("/api/dsh-mneme/memory/tags", {
2004
+ // Two-step delete (no window.confirm): the red button arms, a second
2005
+ // click commits POST /api/dsh-mneme/delete. On success the row leaves
2006
+ // every local view (browse pages + search results), the selection
2007
+ // clears and the total shrinks; failures 404 included surface as
2008
+ // a transient red note next to the actions.
2009
+ const deleteSelected = () => {
2010
+ if (!selected || deleting) return;
2011
+ const id = selected.id;
2012
+ setDeleting(true);
2013
+ setDeleteError(false);
2014
+ apiFetch("/api/dsh-mneme/delete", {
1909
2015
  method: "POST",
1910
2016
  headers: { "Content-Type": "application/json" },
1911
- body: JSON.stringify({ id: selectedId, tags: tagTags.filter((x) => x !== tag) })
1912
- }).then((res) => {
1913
- if (res.status === 409) { setTagManual(false); return; }
1914
- return res.json();
1915
- }).then((j) => {
1916
- if (j?.ok) loadTags(selectedId);
1917
- }).catch(() => {});
1918
- };
1919
- const onTagClick = (tag) => {
1920
- setType("all");
1921
- setQuery(`tag:${tag}`);
2017
+ body: JSON.stringify({ id })
2018
+ })
2019
+ .then((res) => { if (!res.ok) throw new Error("http"); })
2020
+ .then(() => {
2021
+ setItems((cur) => cur.filter((m) => m.id !== id));
2022
+ setRemoteItems((cur) => (cur ? cur.filter((m) => m.id !== id) : cur));
2023
+ setTotal((n) => Math.max(0, n - 1));
2024
+ setSelectedId(null);
2025
+ setConfirmDelete(false);
2026
+ setDeleteMsg(true);
2027
+ setTimeout(() => setDeleteMsg(false), 2500);
2028
+ })
2029
+ .catch(() => {
2030
+ setDeleteError(true);
2031
+ setTimeout(() => setDeleteError(false), 4000);
2032
+ })
2033
+ .finally(() => setDeleting(false));
1922
2034
  };
1923
2035
 
1924
2036
  // Graph → memory jump: land on the browser tab with filters reset so
1925
- // the target row is visible, selected and scrolled into view.
2037
+ // the target row is visible, selected and scrolled into view. A target
2038
+ // beyond the loaded pages renders as a single-row result set instead
2039
+ // of pulling page after page to find it.
1926
2040
  const jumpToMemory = (target) => {
1927
2041
  if (!target?.id) return;
1928
2042
  setView("memory");
1929
2043
  setType("all");
1930
2044
  setQuery("");
1931
- setCollapsed({});
2045
+ setRemoteItems(items.some((m) => m.id === target.id) ? null : [target]);
2046
+ setExpandedMonths(null);
1932
2047
  setSelectedId(target.id);
1933
2048
  };
1934
2049
 
1935
- // [[wiki-link]] click in the detail content: resolve the title to a
1936
- // memory (case-insensitive exact match) then jump to it. Unresolvable
1937
- // titles silently no-op — the link stays as display text.
1938
- const onWikilinkClick = (target) => {
1939
- if (!target) return;
1940
- apiFetch(`/api/dsh-mneme/wikilinks/resolve?title=${encodeURIComponent(target)}`)
1941
- .then((r) => (r.ok ? r.json() : null))
1942
- .then((j) => { if (j?.memory?.id) jumpToMemory(j.memory); })
1943
- .catch(() => {});
1944
- };
1945
-
1946
2050
  const openGraphFor = (name) => {
1947
2051
  setGraphFocus(name || "");
1948
- setView("graph");
1949
- };
1950
-
1951
- // Overview tile click: dive into the memory view filtered to that layer.
1952
- const pickLayerType = (key) => {
1953
- setType(key);
1954
- setView("memory");
2052
+ setView("entity");
1955
2053
  };
1956
2054
 
1957
2055
  const subviews = [
1958
2056
  { key: "memory", label: t("memory.explorer.tabMemory") },
1959
- { key: "overview", label: t("memory.explorer.tabOverview") },
1960
- { key: "directory", label: t("memory.explorer.tabDirectory") },
1961
- { key: "graph", label: t("memory.explorer.tabGraph") },
2057
+ { key: "entity", label: t("memory.explorer.tabEntities") },
2058
+ { key: "status", label: t("memory.explorer.tabStatus") },
1962
2059
  { key: "settings", label: t("memory.explorer.tabSettings") }
1963
2060
  ];
1964
2061
 
@@ -1973,26 +2070,66 @@ window.__ModuleLoader__.load({
1973
2070
  "aria-pressed": String(view === s.key),
1974
2071
  onClick: () => setView(s.key)
1975
2072
  },
1976
- s.key === "graph"
1977
- ? h(react.Fragment, null, h(GraphNodesIcon, { size: 16 }), s.label)
1978
- : s.label
2073
+ h(Icon, { name: s.key === "memory" ? "database" : s.key === "entity" ? "waypoints" : s.key === "status" ? "activity" : "settings", size: 14, className: "mneme-vtabico" }),
2074
+ s.label
1979
2075
  ))
1980
2076
  ),
1981
2077
  ),
1982
2078
  view === "memory" && h("div", { className: "mneme-xmain" },
1983
- // 1. 中间分类栏
1984
- h("div", { className: "mneme-xfilter-bar" },
2079
+ h("div", { className: "mneme-xside" },
2080
+ h("div", { className: "mneme-xcolhead" }, t("memory.explorer.searchTitle")),
2081
+ h("div", { className: "mneme-xsearchwrap" },
2082
+ h(Icon, { name: "search", size: 14, className: "mneme-xsearchico" }),
2083
+ h("input", {
2084
+ className: "mneme-search mneme-xsearch",
2085
+ placeholder: t("memory.explorer.search"),
2086
+ value: query,
2087
+ onChange: (e) => setQuery(e.target.value)
2088
+ })
2089
+ ),
2090
+ entityQuery && h("button", {
2091
+ className: "mneme-entitychip",
2092
+ style: { textAlign: "left", justifyContent: "flex-start" },
2093
+ onClick: () => openGraphFor(entityQuery)
2094
+ }, `${t("memory.graph.viewInGraph")} “${entityQuery}”`),
2095
+ h("div", { className: "mneme-xrow" },
2096
+ vecEnabled && h("button", {
2097
+ className: semantic ? "mneme-chip mneme-active" : "mneme-chip",
2098
+ title: t("memory.settings.vectorTitle"),
2099
+ onClick: () => setSemantic(!semantic)
2100
+ }, t("memory.panel.semantic")),
2101
+ h("select", {
2102
+ className: "mneme-select mneme-xselect",
2103
+ value: searchTopK,
2104
+ onChange: (e) => setSearchTopK(Number(e.target.value)),
2105
+ title: t("memory.explorer.topK")
2106
+ }, [5, 10, 20, 50].map((n) => h("option", { key: n, value: n }, t("memory.explorer.topKOption").replace("{n}", String(n)))))
2107
+ ),
2108
+ h("div", { className: "mneme-xrow" },
2109
+ h("span", { className: "mneme-xcount" }, t("memory.explorer.count").replace("{n}", String(visible.length))),
2110
+ h("button", { className: "mneme-footbtn", onClick: () => setReloadKey((k) => k + 1) },
2111
+ h(Icon, { name: "refresh", size: 12 }), t("memory.explorer.refresh"))
2112
+ )
2113
+ ),
2114
+ h("div", { className: "mneme-xside mneme-xside--filter" },
2115
+ h("div", { className: "mneme-xcolhead" }, t("memory.explorer.importance")),
2116
+ h("div", { className: "mneme-xrow" },
2117
+ [0, 3, 4, 5].map((v) =>
2118
+ h("button", {
2119
+ key: v,
2120
+ className: minImp === v ? "mneme-chip mneme-active" : "mneme-chip",
2121
+ onClick: () => setMinImp(v)
2122
+ }, v === 0 ? t("memory.tab.all") : `★${v}+`))
2123
+ ),
1985
2124
  h("div", { className: "mneme-xcolhead" }, t("memory.explorer.types")),
1986
2125
  h("button", {
1987
2126
  className: type === "all" ? "mneme-xtype mneme-active" : "mneme-xtype",
1988
- "aria-pressed": type === "all",
1989
2127
  onClick: () => setType("all")
1990
2128
  }, h("span", { className: "mneme-xdot mneme-xdot--all", "aria-hidden": "true" }), h("span", null, t("memory.tab.all")), h("span", { className: "mneme-xcount2" }, String(items.length))),
1991
2129
  knownTypes.concat(extraTypes).map((key) =>
1992
2130
  h("button", {
1993
2131
  key,
1994
2132
  className: type === key ? "mneme-xtype mneme-active" : "mneme-xtype",
1995
- "aria-pressed": type === key,
1996
2133
  onClick: () => setType(key)
1997
2134
  },
1998
2135
  h("span", { className: "mneme-xdot", style: { color: memoryTypeColor(key) }, title: typeLabel(t, key), "aria-hidden": "true" }),
@@ -2000,66 +2137,41 @@ window.__ModuleLoader__.load({
2000
2137
  h("span", { className: "mneme-xcount2" }, String(counts[key]))
2001
2138
  ))
2002
2139
  ),
2003
- // 2. 底部三卡片
2004
- h("div", { className: "mneme-xcards" },
2005
- // 左卡:搜索
2006
- h("div", { className: "mneme-card mneme-card--search", role: "region", "aria-label": t("memory.explorer.searchTitle") },
2007
- h("div", { className: "mneme-xcolhead" }, t("memory.explorer.searchTitle")),
2008
- h("input", {
2009
- className: "mneme-search mneme-xsearch",
2010
- "aria-label": t("memory.explorer.search"),
2011
- placeholder: t("memory.explorer.search"),
2012
- value: query,
2013
- onChange: (e) => setQuery(e.target.value)
2014
- }),
2015
- entityQuery && h("button", {
2016
- className: "mneme-entitychip",
2017
- style: { textAlign: "left", justifyContent: "flex-start" },
2018
- onClick: () => openGraphFor(entityQuery)
2019
- }, `${t("memory.graph.viewInGraph")} “${entityQuery}”`),
2020
- h("div", { className: "mneme-xrow" },
2021
- vecEnabled && h("button", {
2022
- className: semantic ? "mneme-chip mneme-active" : "mneme-chip",
2023
- title: t("memory.settings.vectorTitle"),
2024
- onClick: () => setSemantic(!semantic)
2025
- }, t("memory.panel.semantic")),
2026
- h("select", {
2027
- className: "mneme-select mneme-xselect",
2028
- value: searchTopK,
2029
- onChange: (e) => setSearchTopK(Number(e.target.value)),
2030
- title: t("memory.explorer.topK")
2031
- }, [5, 10, 20, 50].map((n) => h("option", { key: n, value: n }, t("memory.explorer.topKOption").replace("{n}", String(n)))))
2032
- ),
2033
- h("div", { className: "mneme-xrow" },
2034
- h("span", { className: "mneme-xcount" }, t("memory.explorer.count").replace("{n}", String(visible.length))),
2035
- h("button", { className: "mneme-footbtn", onClick: () => setReloadKey((k) => k + 1) }, t("memory.explorer.refresh"))
2036
- )
2037
- ),
2038
- // 中卡:时间树
2039
- h("div", { className: "mneme-card mneme-card--tree", role: "region", "aria-label": t("memory.explorer.timeline") },
2140
+ h("div", { className: "mneme-xbrowse" },
2141
+ h("div", { className: "mneme-xtree" },
2040
2142
  h("div", { className: "mneme-xcolhead" }, t("memory.explorer.timeline")),
2041
2143
  loading
2042
2144
  ? h("div", { className: "mneme-xempty" }, "…")
2043
- : months.length === 0
2044
- ? h("div", { className: "mneme-xempty" }, t("memory.explorer.empty"))
2045
- : months.map((month) =>
2046
- h("div", { key: month.key },
2145
+ : visible.length === 0
2146
+ ? h("div", { className: "mneme-xempty" },
2147
+ h(Icon, { name: "inbox", size: 20, className: "mneme-xemptyico" }),
2148
+ t("memory.explorer.empty"))
2149
+ : months.map((month, mi) => {
2150
+ // Only the newest month starts expanded; older months
2151
+ // stay a one-line header until clicked — a several-
2152
+ // thousand-row store renders a handful of DOM nodes.
2153
+ const open = expandedMonths ? !!expandedMonths[month.key] : mi === 0;
2154
+ return h("div", { key: month.key },
2047
2155
  h("button", {
2048
2156
  className: "mneme-xmonth",
2049
- "aria-expanded": String(!collapsed[month.key]),
2050
- onClick: () => setCollapsed((c) => ({ ...c, [month.key]: !c[month.key] }))
2157
+ "aria-expanded": String(open),
2158
+ onClick: () => setExpandedMonths((c) => {
2159
+ const base = c || (months.length ? { [months[0].key]: true } : {});
2160
+ return { ...base, [month.key]: !open };
2161
+ })
2051
2162
  },
2052
- h("span", { className: "mneme-xcaret" }, collapsed[month.key] ? "" : ""),
2053
- month.label
2163
+ h("span", { className: "mneme-xcaret" }, h(Icon, { name: open ? "chevronDown" : "chevronRight", size: 12 })),
2164
+ h("span", null, month.label),
2165
+ h("span", { className: "mneme-xmonthcount" }, String(month.count))
2054
2166
  ),
2055
- !collapsed[month.key] && month.days.map((day) =>
2167
+ open && month.days.map((day) =>
2056
2168
  h("div", { key: day.key },
2057
2169
  h("button", {
2058
2170
  className: "mneme-xday",
2059
2171
  "aria-expanded": String(!collapsed[day.key]),
2060
2172
  onClick: () => setCollapsed((c) => ({ ...c, [day.key]: !c[day.key] }))
2061
2173
  },
2062
- h("span", { className: "mneme-xcaret" }, collapsed[day.key] ? "" : ""),
2174
+ h("span", { className: "mneme-xcaret" }, h(Icon, { name: collapsed[day.key] ? "chevronRight" : "chevronDown", size: 11 })),
2063
2175
  day.label
2064
2176
  ),
2065
2177
  !collapsed[day.key] && day.items.map((m) => {
@@ -2078,12 +2190,19 @@ window.__ModuleLoader__.load({
2078
2190
  h("span", { className: "mneme-xname" }, m.title || m.content?.slice(0, 40))
2079
2191
  );
2080
2192
  })
2081
- )
2082
- )
2083
- )
2084
- )),
2085
- // 右卡:详情
2086
- h("div", { className: "mneme-card mneme-card--detail", role: "region", "aria-label": t("memory.explorer.detail") },
2193
+ ))
2194
+ );
2195
+ }),
2196
+ h("div", { ref: moreRef, className: "mneme-xmore" },
2197
+ canLoadMore
2198
+ ? h("button", { className: "mneme-footbtn", disabled: loadingMore, onClick: loadMore },
2199
+ loadingMore ? "…" : `${t("memory.explorer.loadMore")}(${items.length}/${total})`)
2200
+ : (!loading && visible.length > 0 && !q && !remoteItems
2201
+ ? h("span", { className: "mneme-xcount" }, `${items.length} / ${total}`)
2202
+ : null)
2203
+ )
2204
+ ),
2205
+ h("div", { className: "mneme-xdetail" },
2087
2206
  selected
2088
2207
  ? h(react.Fragment, { key: selected.id },
2089
2208
  h("div", { className: "mneme-xdinner" },
@@ -2099,64 +2218,46 @@ window.__ModuleLoader__.load({
2099
2218
  h("span", { title: formatDate(selected.created_at) }, `${t("memory.explorer.created")}: ${formatDateShort(selected.created_at)}`),
2100
2219
  h("span", { title: formatDate(selected.updated_at) }, `${t("memory.explorer.updated")}: ${formatRelativeTime(selected.updated_at, t)}`)
2101
2220
  ),
2102
- h("div", { className: "mneme-xdmeta" },
2103
- h("span", null, t("memory.explorer.tags")),
2104
- h("div", { className: "mneme-tagrow" },
2105
- tagTags.length > 0
2106
- ? tagTags.map((tag) =>
2107
- h("span", {
2108
- key: tag,
2109
- className: "mneme-tagchip",
2110
- title: `tag:${tag}`,
2111
- onClick: () => onTagClick(tag)
2112
- },
2113
- tag,
2114
- tagManual && h("button", {
2115
- type: "button",
2116
- className: "mneme-tagremove",
2117
- "aria-label": `${t("memory.explorer.tagRemove")}: ${tag}`,
2118
- onClick: (e) => { e.stopPropagation(); removeTag(tag); }
2119
- }, "×")
2120
- )
2121
- )
2122
- : h("span", { className: "mneme-tagempty" }, t("memory.explorer.tagsEmpty")),
2123
- tagManual && (tagAdding
2124
- ? h("input", {
2125
- key: selectedId,
2126
- className: "mneme-taginput",
2127
- autoFocus: true,
2128
- value: tagInput,
2129
- placeholder: t("memory.explorer.tagPlaceholder"),
2130
- onChange: (e) => setTagInput(e.target.value),
2131
- onKeyDown: (e) => { if (e.key === "Enter") submitTag(); },
2132
- onBlur: () => { setTagAdding(false); setTagInput(""); }
2133
- })
2134
- : h("button", {
2135
- type: "button",
2136
- className: "mneme-tagadd",
2137
- onClick: () => setTagAdding(true)
2138
- }, `+ ${t("memory.explorer.tagAdd")}`))
2139
- )
2140
- ),
2141
- h("div", { className: "mneme-xdcontent" },
2142
- selected.content ? wikilinkSegments(selected.content, onWikilinkClick) : selected.content
2221
+ Array.isArray(selected.tags) && selected.tags.length > 0 && h("div", { className: "mneme-xdmeta" },
2222
+ h("span", null, `${t("memory.explorer.tags")}: ${selected.tags.join(" · ")}`)
2143
2223
  ),
2224
+ h("div", { className: "mneme-xdcontent" }, selected.content),
2144
2225
  h("div", { className: "mneme-xdactions" },
2145
2226
  h("button", { className: "mneme-footbtn", onClick: copyContent },
2146
- copied ? t("memory.explorer.copied") : t("memory.explorer.copy"))
2147
- ),
2148
- h(BacklinksPanel, { memory: selected, t, onJump: jumpToMemory })
2227
+ h(Icon, { name: copied ? "check" : "copy", size: 12 }),
2228
+ copied ? t("memory.explorer.copied") : t("memory.explorer.copy")),
2229
+ // two-step delete: red outline arms, solid red commits
2230
+ confirmDelete
2231
+ ? h(react.Fragment, null,
2232
+ h("button", {
2233
+ className: "mneme-btn mneme-btndangerconfirm",
2234
+ disabled: deleting,
2235
+ onClick: deleteSelected
2236
+ }, t("memory.explorer.confirmDelete")),
2237
+ h("button", {
2238
+ className: "mneme-btn",
2239
+ onClick: () => setConfirmDelete(false)
2240
+ }, t("memory.explorer.cancel"))
2241
+ )
2242
+ : h("button", {
2243
+ className: "mneme-btn mneme-btndanger",
2244
+ onClick: () => setConfirmDelete(true)
2245
+ }, t("memory.explorer.delete")),
2246
+ deleteError && h("span", { style: { fontSize: 12, color: "var(--dsw-alias-state-error,#c33)" } },
2247
+ t("memory.explorer.deleteFailed"))
2248
+ )
2149
2249
  )
2150
2250
  )
2151
- : h("div", { className: "mneme-xempty" }, t("memory.explorer.emptyDetail"))
2251
+ : h("div", { className: "mneme-xempty" },
2252
+ deleteMsg && h("div", { className: "mneme-saved", style: { marginBottom: 6 } }, t("memory.explorer.deleted")),
2253
+ t("memory.explorer.emptyDetail"))
2152
2254
  )
2153
2255
  )
2154
2256
  ),
2155
- view === "overview" && h(OverviewPanel, { t, onPickType: pickLayerType }),
2156
- view === "directory" && h(DirectoryPanel, { t, onJump: jumpToMemory, collapsed: dirCollapsed, setCollapsed: setDirCollapsed }),
2157
- view === "graph" && h(GraphPanel, { t, focusEntity: graphFocus, onJumpMemory: jumpToMemory }),
2158
- view === "settings" && h("div", { className: "mneme-xsettings" },
2159
- h("div", { className: "mneme-xsettings-inner" }, h(SettingsContent, { t }))
2257
+ view === "entity" && h(EntityPanel, { t, focusEntity: graphFocus, onJumpMemory: jumpToMemory }),
2258
+ view === "status" && h(StatusPanel, { t }),
2259
+ view === "settings" && h("div", { className: "mneme-set" },
2260
+ h("div", { className: "mneme-set-inner" }, h(SettingsContent, { t }))
2160
2261
  )
2161
2262
  );
2162
2263
  }
@@ -2167,22 +2268,7 @@ window.__ModuleLoader__.load({
2167
2268
  // opens the full-viewport overlay instead — the library stays reachable
2168
2269
  // from every conversation state.
2169
2270
  function SidebarTrigger({ wide, t }) {
2170
- // issue #38: the entrance button is now optional (settings-over-config
2171
- // toggle, persisted via /api/dsh-mneme/config). While the value loads we
2172
- // render the button (default = visible) so it never flashes out; if the
2173
- // user disabled it, it drops away after the config resolves. The library
2174
- // itself is unaffected — the 记忆库 conversation tab stays.
2175
- const [visible, setVisible] = useState(true);
2176
- useEffect(() => {
2177
- let cancelled = false;
2178
- apiFetch("/api/dsh-mneme/config")
2179
- .then((res) => res.json())
2180
- .then((d) => { if (!cancelled) setVisible(d.config?.showSidebarTrigger !== false); })
2181
- .catch(() => { if (!cancelled) setVisible(true); });
2182
- return () => { cancelled = true; };
2183
- }, []);
2184
2271
  const [, setOpen] = useOverlayOpen();
2185
- if (!visible) return null;
2186
2272
  return h("button", {
2187
2273
  type: "button",
2188
2274
  className: wide ? "mneme-trigger" : "mneme-trigger mneme-rail",