@modusensus/dsh-mneme 0.4.7 → 0.5.1

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.
package/lib/client.js CHANGED
@@ -5,12 +5,42 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports;
6
6
 
7
7
  let react = require("react");
8
- let reactDom = require("react-dom");
8
+ let primitives = require("@deepseek-ai/dsh-client-ui-primitives");
9
9
  let { useState, useEffect, useCallback, useRef } = react;
10
- let { createPortal } = reactDom;
10
+ const IconArchiveOutline20 = primitives.IconArchiveOutline20;
11
+
12
+ // Portal target for the hero fallback surface. The host whitelists
13
+ // react-dom for its own bundles (dsh-client-ui-trajectory requires it);
14
+ // when the runtime rejects it for plugins the overlay renders in place —
15
+ // position:fixed keeps it viewport-sized either way.
16
+ let reactDom = null;
17
+ try { reactDom = require("react-dom"); } catch { reactDom = null; }
18
+
19
+ // Node-graph pictogram (three nodes joined by edges). The primitives kit
20
+ // ships no network/graph icon, and its share-style glyph reads as
21
+ // "share" — exactly the confusion this custom 16px replacement avoids.
22
+ const GraphNodesIcon = ({ size = 16, className }) => h("svg", {
23
+ width: size,
24
+ height: size,
25
+ className,
26
+ viewBox: "0 0 16 16",
27
+ fill: "none",
28
+ xmlns: "http://www.w3.org/2000/svg"
29
+ },
30
+ h("path", {
31
+ d: "M8 5.2 4.6 10.4M8 5.2l3.4 5.2M5.2 12h5.6",
32
+ stroke: "currentColor",
33
+ strokeWidth: "1.2",
34
+ strokeLinecap: "round",
35
+ strokeLinejoin: "round"
36
+ }),
37
+ h("circle", { cx: 8, cy: 3.4, r: 1.8, fill: "currentColor" }),
38
+ h("circle", { cx: 3.6, cy: 12, r: 1.8, fill: "currentColor" }),
39
+ h("circle", { cx: 12.4, cy: 12, r: 1.8, fill: "currentColor" })
40
+ );
11
41
 
12
42
  // Unified API fetcher: attaches the optional apiToken (set in the settings
13
- // panel, persisted in localStorage) as a Bearer header. When no token has
43
+ // view, persisted in localStorage) as a Bearer header. When no token has
14
44
  // been configured the header is omitted and the API stays open (default).
15
45
  const API_TOKEN_KEY = "dsh-mneme-api-token";
16
46
  function apiFetch(path, opts = {}) {
@@ -28,8 +58,6 @@ window.__ModuleLoader__.load({
28
58
 
29
59
  const dictionaries = {
30
60
  zh: {
31
- "memory.panel.title": "记忆库",
32
- "memory.panel.search": "搜索记忆…",
33
61
  "memory.panel.empty": "暂无记忆条目",
34
62
  "memory.panel.open": "记忆",
35
63
  "memory.tab.all": "全部",
@@ -37,7 +65,6 @@ window.__ModuleLoader__.load({
37
65
  "memory.tab.project": "项目",
38
66
  "memory.tab.decision": "决策",
39
67
  "memory.tab.history": "历史",
40
- "memory.settings.open": "设置",
41
68
  "memory.settings.title": "记忆库设置",
42
69
  "memory.settings.profile": "用户画像",
43
70
  "memory.settings.profileHint": "描述你自己(角色、背景、偏好),Agent 会在每轮遵循",
@@ -56,6 +83,50 @@ window.__ModuleLoader__.load({
56
83
  "memory.settings.cmdDelete": "删除",
57
84
  "memory.settings.empty": "暂无内容",
58
85
  "memory.panel.semantic": "语义",
86
+ "memory.sidebar.aria": "打开记忆库",
87
+ "memory.view.label": "记忆库",
88
+ "memory.overlay.close": "关闭",
89
+ "memory.explorer.tabMemory": "记忆",
90
+ "memory.explorer.tabGraph": "图谱",
91
+ "memory.explorer.tabSettings": "设置",
92
+ "memory.explorer.search": "搜索标题或内容…",
93
+ "memory.explorer.searchTitle": "语义检索",
94
+ "memory.explorer.types": "分类",
95
+ "memory.explorer.timeline": "时间树",
96
+ "memory.explorer.detail": "详情",
97
+ "memory.explorer.emptyDetail": "在时间树中选择一条记忆查看全文",
98
+ "memory.explorer.copy": "复制全文",
99
+ "memory.explorer.copied": "已复制",
100
+ "memory.explorer.refresh": "刷新",
101
+ "memory.explorer.count": "共 {n} 条",
102
+ "memory.explorer.empty": "暂无记忆条目",
103
+ "memory.explorer.source": "来源",
104
+ "memory.explorer.created": "创建",
105
+ "memory.explorer.updated": "更新",
106
+ "memory.explorer.tags": "标签",
107
+ "memory.explorer.importance": "重要性",
108
+ "memory.explorer.topK": "返回数量",
109
+ "memory.explorer.topKOption": "返回 {n} 条",
110
+ "memory.card.open": "在主区记忆库中查看全文",
111
+ "memory.time.now": "刚刚",
112
+ "memory.time.seconds": "{n}秒前",
113
+ "memory.time.minutes": "{n}分钟前",
114
+ "memory.time.hours": "{n}小时前",
115
+ "memory.time.days": "{n}天前",
116
+ "memory.graph.title": "记忆图谱",
117
+ "memory.graph.aria": "记忆图谱",
118
+ "memory.graph.placeholder": "输入实体名,查看关联网络…",
119
+ "memory.graph.depth": "跳数",
120
+ "memory.graph.empty": "图谱待积累:随对话记忆的沉淀,实体与关系会自动进入图谱",
121
+ "memory.graph.notFound": "未找到该实体",
122
+ "memory.graph.attrs": "属性",
123
+ "memory.graph.related": "关联记忆",
124
+ "memory.graph.relation": "关系",
125
+ "memory.graph.sourceMemory": "查看来源记忆",
126
+ "memory.graph.hint": "点击节点展开 · 拖拽调整布局",
127
+ "memory.graph.viewInGraph": "在图谱中查看",
128
+ "memory.graph.loading": "加载中…",
129
+ "memory.graph.distance": "距中心 {n} 跳",
59
130
  "memory.settings.vectorTitle": "向量搜索",
60
131
  "memory.settings.vectorHint": "接入 OpenAI 兼容的 embeddings API 做语义搜索,可匹配字面不同但语义相近的记忆",
61
132
  "memory.settings.vectorEnabled": "启用向量搜索",
@@ -74,8 +145,6 @@ window.__ModuleLoader__.load({
74
145
  "memory.settings.apiTokenSaved": "Token 已保存"
75
146
  },
76
147
  en: {
77
- "memory.panel.title": "Memory",
78
- "memory.panel.search": "Search memories…",
79
148
  "memory.panel.empty": "No memories yet",
80
149
  "memory.panel.open": "Memory",
81
150
  "memory.tab.all": "All",
@@ -83,7 +152,6 @@ window.__ModuleLoader__.load({
83
152
  "memory.tab.project": "Projects",
84
153
  "memory.tab.decision": "Decisions",
85
154
  "memory.tab.history": "History",
86
- "memory.settings.open": "Settings",
87
155
  "memory.settings.title": "Memory Settings",
88
156
  "memory.settings.profile": "User Profile",
89
157
  "memory.settings.profileHint": "Describe yourself — the agent follows this every turn",
@@ -102,6 +170,50 @@ window.__ModuleLoader__.load({
102
170
  "memory.settings.cmdDelete": "Delete",
103
171
  "memory.settings.empty": "Nothing yet",
104
172
  "memory.panel.semantic": "Semantic",
173
+ "memory.sidebar.aria": "Open memory panel",
174
+ "memory.view.label": "Memory",
175
+ "memory.overlay.close": "Close",
176
+ "memory.explorer.tabMemory": "Memories",
177
+ "memory.explorer.tabGraph": "Graph",
178
+ "memory.explorer.tabSettings": "Settings",
179
+ "memory.explorer.search": "Search title or content…",
180
+ "memory.explorer.searchTitle": "Semantic Search",
181
+ "memory.explorer.types": "Types",
182
+ "memory.explorer.timeline": "Timeline",
183
+ "memory.explorer.detail": "Details",
184
+ "memory.explorer.emptyDetail": "Select a memory in the timeline to read it",
185
+ "memory.explorer.copy": "Copy content",
186
+ "memory.explorer.copied": "Copied",
187
+ "memory.explorer.refresh": "Refresh",
188
+ "memory.explorer.count": "{n} items",
189
+ "memory.explorer.empty": "No memories yet",
190
+ "memory.explorer.source": "Source",
191
+ "memory.explorer.created": "Created",
192
+ "memory.explorer.updated": "Updated",
193
+ "memory.explorer.tags": "Tags",
194
+ "memory.explorer.importance": "Importance",
195
+ "memory.explorer.topK": "Results limit",
196
+ "memory.explorer.topKOption": "Return {n}",
197
+ "memory.card.open": "Open full text in the Memory tab",
198
+ "memory.time.now": "just now",
199
+ "memory.time.seconds": "{n}s ago",
200
+ "memory.time.minutes": "{n}m ago",
201
+ "memory.time.hours": "{n}h ago",
202
+ "memory.time.days": "{n}d ago",
203
+ "memory.graph.title": "Memory Graph",
204
+ "memory.graph.aria": "Memory graph",
205
+ "memory.graph.placeholder": "Type an entity name to see its network…",
206
+ "memory.graph.depth": "hops",
207
+ "memory.graph.empty": "The graph is waiting for data: entities and relations accumulate as memories are extracted",
208
+ "memory.graph.notFound": "Entity not found",
209
+ "memory.graph.attrs": "Attributes",
210
+ "memory.graph.related": "Related memories",
211
+ "memory.graph.relation": "Relation",
212
+ "memory.graph.sourceMemory": "View source memory",
213
+ "memory.graph.hint": "Click a node to expand · drag to rearrange",
214
+ "memory.graph.viewInGraph": "View in graph",
215
+ "memory.graph.loading": "Loading…",
216
+ "memory.graph.distance": "{n} hop(s) from root",
105
217
  "memory.settings.vectorTitle": "Vector Search",
106
218
  "memory.settings.vectorHint": "Connect an OpenAI-compatible embeddings API for semantic search by meaning, not just keywords",
107
219
  "memory.settings.vectorEnabled": "Enable vector search",
@@ -133,109 +245,425 @@ window.__ModuleLoader__.load({
133
245
  return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString();
134
246
  }
135
247
 
136
- function MemoryPanel({ t, onClose, embedded }) {
137
- const [tab, setTab] = useState("all");
138
- const [query, setQuery] = useState("");
139
- const [semantic, setSemantic] = useState(false);
140
- const [vecEnabled, setVecEnabled] = useState(false);
141
- const [items, setItems] = useState([]);
142
- const [loading, setLoading] = useState(false);
143
- const abortRef = useRef(null);
248
+ // Relative time ("2分钟前") keeps cards scannable; anything older than a
249
+ // week falls back to the absolute date. The card meta shows the relative
250
+ // form and carries the full timestamp in a title tooltip.
251
+ function formatRelativeTime(value, t) {
252
+ if (!value) return "—";
253
+ const ms = new Date(value).getTime();
254
+ if (Number.isNaN(ms)) return "—";
255
+ const diff = Date.now() - ms;
256
+ if (diff < 60_000) return t("memory.time.now");
257
+ const minutes = Math.floor(diff / 60_000);
258
+ if (minutes < 60) return t("memory.time.minutes").replace("{n}", String(minutes));
259
+ const hours = Math.floor(minutes / 60);
260
+ if (hours < 24) return t("memory.time.hours").replace("{n}", String(hours));
261
+ const days = Math.floor(hours / 24);
262
+ if (days < 7) return t("memory.time.days").replace("{n}", String(days));
263
+ return new Date(ms).toLocaleDateString();
264
+ }
265
+
266
+ // Memory-library stylesheet, injected once per page following the host's
267
+ // data-plugin-css convention. Every value resolves to the host's design
268
+ // tokens: background layers, label/border/interactive aliases and the
269
+ // --dsw-font-* scale, so the page reads as a first-party view beside
270
+ // Chat / Trajectory (flat layer-1 canvas, hairline column separators,
271
+ // text-turns-brand-blue active states — no boxed panels).
272
+ const CSS_TAG = "@modusensus/dsh-mneme/drawer.css";
273
+ const css = [
274
+ // --- sidebar foot trigger (wide row / collapsed rail icon) ---
275
+ ".mneme-trigger{box-sizing:border-box;cursor:pointer;width:calc(100% + 4px);height:42px;color:var(--dsw-alias-label-primary);background:0 0;border:none;border-radius:12px;flex:none;align-items:center;gap:8px;margin:4px -2px;padding:0 10px 0 8px;font-family:inherit;font-size:14px;line-height:22px;display:flex;overflow:hidden}",
276
+ ".mneme-trigger:hover{background:var(--dsw-alias-interactive-bg-hover)}",
277
+ ".mneme-trigger.mneme-rail{border-radius:50%;justify-content:center;gap:0;width:36px;height:36px;margin:8px 0 10px;padding:0}",
278
+ ".mneme-trigger-label{white-space:nowrap;overflow:hidden}",
279
+ // --- shared controls ---
280
+ ".mneme-search{box-sizing:border-box;height:30px;padding:0 10px;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;transition:border-color .12s,box-shadow .12s}",
281
+ ".mneme-search:focus{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-state-business-primary) 15%,transparent)}",
282
+ ".mneme-search::placeholder{color:var(--dsw-alias-label-tertiary)}",
283
+ ".mneme-chip{height:26px;padding:0 10px;border-radius:8px;border:1px solid transparent;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-family:inherit;font-size:12px;line-height:16px;display:inline-flex;align-items:center}",
284
+ ".mneme-chip:hover{background:var(--dsw-alias-interactive-bg-hover)}",
285
+ ".mneme-chip.mneme-active{color:var(--dsw-alias-state-business-primary);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent)}",
286
+ ".mneme-select{box-sizing:border-box;height:30px;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:13px;outline:none}",
287
+ ".mneme-footbtn{border:none;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-family:inherit;font-size:12px;line-height:16px;padding:3px 8px;border-radius:6px}",
288
+ ".mneme-footbtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
289
+ ".mneme-hint{color:var(--dsw-alias-label-tertiary);padding:24px 0;text-align:center;font-size:13px}",
290
+ ".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}",
291
+ ".mneme-entitychip:hover{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent)}",
292
+ // --- main-area memory library page ---
293
+ ".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)}",
294
+ ".mneme-xbar{flex:none;display:flex;align-items:center;gap:6px;border-bottom:1px solid var(--dsw-alias-border-l2);padding:0 16px}",
295
+ ".mneme-filterbar{flex:none;display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 16px;border-bottom:1px solid var(--dsw-alias-border-l2)}",
296
+ ".mneme-vtabs{display:flex;align-items:stretch;height:38px}",
297
+ ".mneme-vtab{position:relative;border:0;background:none;cursor:pointer;padding:0 10px;color:var(--dsw-alias-label-tertiary);font-family:inherit;font-size:13px;font-weight:500;line-height:16px;display:inline-flex;align-items:center;gap:6px}",
298
+ ".mneme-vtab:hover{color:var(--dsw-alias-label-primary)}",
299
+ ".mneme-vtab.mneme-active{color:var(--dsw-alias-state-business-primary)}",
300
+ ".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)}",
301
+ ".mneme-xtools{margin-left:auto;display:flex;align-items:center;gap:8px;padding:0 0 0 12px}",
302
+ ".mneme-xcount{flex:none;font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}",
303
+ // --- three-column browse layout: hairline separators, no outer box ---
304
+ ".mneme-xmain{flex:1;min-height:0;display:flex;flex-direction:row;overflow:hidden}",
305
+ ".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}",
306
+ ".mneme-xside--filter{width:214px}",
307
+ ".mneme-xbrowse{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;overflow:hidden}",
308
+ ".mneme-xrow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
309
+ ".mneme-xsearch{width:100%}",
310
+ ".mneme-xselect{flex:1;min-width:0}",
311
+ ".mneme-xcolhead{flex:none;font-size:12px;font-weight:500;color:var(--dsw-alias-label-tertiary);padding:2px 8px 8px}",
312
+ ".mneme-xtype{display:flex;justify-content:space-between;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}",
313
+ ".mneme-xtype:hover{background:var(--dsw-alias-interactive-bg-hover)}",
314
+ ".mneme-xtype.mneme-active{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary);font-weight:500}",
315
+ ".mneme-xcount2{flex:none;font-size:12px;color:var(--dsw-alias-label-tertiary)}",
316
+ ".mneme-xmonth{display:flex;align-items:center;gap:4px;width:100%;padding:6px 8px 4px;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}",
317
+ ".mneme-xmonth:first-child{margin-top:0}",
318
+ ".mneme-xmonth:hover{background:var(--dsw-alias-interactive-bg-hover)}",
319
+ ".mneme-xcaret{flex:none;font-size:10px;color:var(--dsw-alias-label-tertiary);width:10px}",
320
+ ".mneme-xday{display:flex;align-items:center;gap:4px;margin:6px 0 2px 22px;padding:2px 6px;border:none;border-radius:6px;background:none;font-family:inherit;font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);cursor:pointer;text-align:left}",
321
+ ".mneme-xday:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
322
+ ".mneme-xitem{display:flex;gap:8px;align-items:baseline;width:calc(100% - 22px);margin-left:22px;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}",
323
+ ".mneme-xitem:hover{background:var(--dsw-alias-interactive-bg-hover)}",
324
+ ".mneme-xitem.mneme-active{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary)}",
325
+ ".mneme-xtime{flex:none;font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}",
326
+ ".mneme-xname{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
327
+ ".mneme-xempty{padding:32px 16px;text-align:center;color:var(--dsw-alias-label-tertiary);font-size:13px}",
328
+ // --- detail column ---
329
+ ".mneme-xdetail{flex:none;max-height:44%;min-height:0;overflow-y:auto;padding:14px 20px 16px;border-bottom:1px solid var(--dsw-alias-border-l2)}",
330
+ ".mneme-xtree{flex:1;min-height:0;overflow-y:auto;padding:8px 10px 28px}",
331
+ ".mneme-xdinner{max-width:720px}",
332
+ ".mneme-xdtitle{font-size:16px;font-weight:600;line-height:24px;color:var(--dsw-alias-label-primary);margin-bottom:10px;word-break:break-word}",
333
+ ".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)}",
334
+ ".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}",
335
+ ".mneme-xdactions{display:flex;gap:8px;margin-top:18px}",
336
+ // --- graph sub-view (fills the content area under the tabs) ---
337
+ ".mneme-graph{flex:1;min-height:0;width:100%;display:flex;flex-direction:column;padding:12px 16px 16px;box-sizing:border-box}",
338
+ ".mneme-graphbar{display:flex;gap:8px;align-items:center;flex:none;margin-bottom:10px}",
339
+ ".mneme-graphsvg{flex:1;min-height:180px;width:100%;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-button-elevated-fill);cursor:grab;touch-action:none}",
340
+ ".mneme-gnode{cursor:pointer}",
341
+ ".mneme-gnode circle{stroke:var(--dsw-alias-bg-layer-2);stroke-width:2;transition:stroke-width .12s}",
342
+ ".mneme-gnode:hover circle{stroke-width:4}",
343
+ ".mneme-gnode.mneme-groot circle{stroke:var(--dsw-alias-state-business-primary);stroke-width:3}",
344
+ ".mneme-glabel{fill:var(--dsw-alias-label-secondary);font-size:11px;text-anchor:middle;pointer-events:none;user-select:none}",
345
+ ".mneme-gedge{stroke:var(--dsw-alias-label-dimmed);stroke-width:1.5;cursor:pointer}",
346
+ ".mneme-gedge:hover{stroke:var(--dsw-alias-state-business-primary)}",
347
+ ".mneme-gedge-dashed{stroke-dasharray:5 4;opacity:.7}",
348
+ ".mneme-graphhint{flex:none;color:var(--dsw-alias-label-tertiary);font-size:13px;text-align:center;padding:6px 0 2px}",
349
+ ".mneme-graphside{flex:none;max-height:220px;overflow-y:auto;border-top:1px solid var(--dsw-alias-border-l1);margin-top:10px;padding-top:10px}",
350
+ ".mneme-gs-title{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary);margin-bottom:4px;display:flex;justify-content:space-between;align-items:baseline;gap:8px}",
351
+ ".mneme-gs-meta{font-size:13px;color:var(--dsw-alias-label-tertiary);margin-bottom:6px}",
352
+ ".mneme-gs-attr{display:flex;gap:6px;font-size:13px;padding:2px 0}",
353
+ ".mneme-gs-attrkey{flex:none;color:var(--dsw-alias-label-tertiary);font-size:13px}",
354
+ ".mneme-gs-attrval{color:var(--dsw-alias-label-secondary);word-break:break-word;font-size:13px}",
355
+ ".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}",
356
+ ".mneme-gs-link:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
357
+ // --- settings sub-view ---
358
+ ".mneme-xsettings{flex:1;min-height:0;overflow-y:auto;padding:24px 24px 48px}",
359
+ ".mneme-xsettings-inner{max-width:640px}",
360
+ // --- hero fallback: full-viewport memory library when no tab ring exists ---
361
+ // The host hides the whole conversation tab ring while a session is
362
+ // blank (hero screen), so the sidebar entry cannot activate the tab
363
+ // there. This surface reuses the exact MemoryExplorer UI at full size —
364
+ // not a side drawer — so the library stays reachable from any state.
365
+ ".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}",
366
+ ".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)}",
367
+ ".mneme-overlaytitle{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}",
368
+ ".mneme-overlaybody{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}",
369
+ "@keyframes mneme-fadein{from{opacity:0}to{opacity:1}}"
370
+ ].join("\n");
371
+ if (typeof document !== "undefined" && document.querySelector(`style[data-plugin-css="${CSS_TAG}"]`) === null) {
372
+ const tag = document.createElement("style");
373
+ tag.dataset.plugin = "@modusensus/dsh-mneme";
374
+ tag.dataset.pluginCss = CSS_TAG;
375
+ tag.textContent = css;
376
+ document.head.appendChild(tag);
377
+ }
378
+
379
+ const h = react.createElement;
380
+
381
+ // --- graph view constants ---
382
+ // Entity type → node fill. The host has no palette token for categorical
383
+ // data, so these are fixed hues tuned for both light and dark themes
384
+ // (medium saturation, similar luminance).
385
+ const TYPE_COLORS = {
386
+ person: "#3b82f6",
387
+ project: "#22c55e",
388
+ concept: "#a855f7",
389
+ technology: "#f59e0b",
390
+ organization: "#06b6d4"
391
+ };
392
+ function typeColor(type) {
393
+ return TYPE_COLORS[type] || "#94a3b8";
394
+ }
395
+ function nodeRadius(n) {
396
+ // mention_count → area-ish growth, clamped so hubs stay legible.
397
+ return 7 + Math.min(20, Math.max(1, n.mention_count ?? 1)) * 0.55;
398
+ }
399
+
400
+ // Deterministic golden-angle spiral: no two nodes start overlapping, and
401
+ // re-running the layout for the same data is stable (no random seeding).
402
+ function initialPositions(nodes, width, height) {
403
+ const cx = width / 2;
404
+ const cy = height / 2;
405
+ return nodes.map((n, i) => {
406
+ const r = i === 0 ? 0 : 34 + 13 * Math.sqrt(i);
407
+ const a = i * 2.39996;
408
+ return { ...n, x: cx + r * Math.cos(a), y: cy + r * Math.sin(a), vx: 0, vy: 0, pinned: false };
409
+ });
410
+ }
411
+
412
+ // --- Graph view: ego-graph of one entity, zero-dependency SVG force layout ---
413
+ // The DSH client module table only whitelists platform modules, so a graph
414
+ // library like vis-network cannot be required from a plugin. A hand-rolled
415
+ // spring simulation (repulsion + edge springs + center gravity, damped) is
416
+ // plenty for the ≤40 nodes the ego API returns.
417
+ function GraphPanel({ t, focusEntity, onJumpMemory }) {
418
+ const [entityName, setEntityName] = useState(focusEntity || "");
419
+ const [inputValue, setInputValue] = useState(focusEntity || "");
420
+ const [depth, setDepth] = useState(1);
421
+ const [data, setData] = useState(null);
422
+ const [status, setStatus] = useState("idle"); // idle | loading | ready | notfound | error
423
+ const [selected, setSelected] = useState(null); // { kind: "node"|"edge", node?|edge? }
424
+ const [attrs, setAttrs] = useState([]);
425
+ const [related, setRelated] = useState([]);
426
+ const svgRef = useRef(null);
427
+ const posRef = useRef([]); // live simulation positions, not React state
428
+ const dragRef = useRef(null); // { id, moved }
429
+ const [frame, setFrame] = useState(0); // re-render tick driven by the simulation
144
430
 
145
431
  useEffect(() => {
146
- apiFetch("/api/dsh-mneme/vector-config")
147
- .then((res) => res.json())
148
- .then((d) => setVecEnabled(!!d.config?.enabled))
149
- .catch(() => {});
150
- }, []);
432
+ if (focusEntity && focusEntity !== entityName) {
433
+ setEntityName(focusEntity);
434
+ setInputValue(focusEntity);
435
+ }
436
+ // eslint-disable-next-line react-hooks/exhaustive-deps
437
+ }, [focusEntity]);
151
438
 
152
- const load = useCallback(async () => {
153
- abortRef.current?.abort();
154
- const controller = new AbortController();
155
- abortRef.current = controller;
156
- setLoading(true);
439
+ const load = useCallback(async (name, d) => {
440
+ if (!name) { setData(null); setStatus("idle"); return; }
441
+ setStatus("loading");
442
+ setSelected(null);
157
443
  try {
158
- const params = new URLSearchParams();
159
- if (tab !== "all") params.set("type", tab);
160
- const url = query.trim()
161
- ? `/api/dsh-mneme/search?q=${encodeURIComponent(query.trim())}&mode=${semantic ? "vector" : "auto"}`
162
- : `/api/dsh-mneme/list?${params.toString()}`;
163
- const res = await apiFetch(url, { signal: controller.signal });
164
- const data = await res.json();
165
- setItems(data.items || []);
166
- } catch (error) {
167
- if (error.name === "AbortError") return;
168
- setItems([]);
169
- } finally {
170
- setLoading(false);
444
+ const res = await apiFetch(`/api/dsh-mneme/semantic/graph/ego?entity=${encodeURIComponent(name)}&depth=${d}`);
445
+ if (res.status === 404) { setData(null); setStatus("notfound"); return; }
446
+ if (!res.ok) { setData(null); setStatus("error"); return; }
447
+ const json = await res.json();
448
+ setData(json);
449
+ setStatus("ready");
450
+ } catch {
451
+ setData(null);
452
+ setStatus("error");
171
453
  }
172
- }, [tab, query, semantic]);
454
+ }, []);
173
455
 
456
+ useEffect(() => { load(entityName, depth); }, [load, entityName, depth]);
457
+
458
+ // Detail pane: node → current attrs + entity: search for related memories.
174
459
  useEffect(() => {
175
- load();
176
- return () => abortRef.current?.abort();
177
- }, [load]);
460
+ setAttrs([]);
461
+ setRelated([]);
462
+ if (!selected || selected.kind !== "node") return;
463
+ const name = selected.node.name;
464
+ let cancelled = false;
465
+ apiFetch(`/api/dsh-mneme/semantic/graph/entity-attrs?entity=${encodeURIComponent(name)}`)
466
+ .then((r) => (r.ok ? r.json() : { attrs: [] }))
467
+ .then((j) => { if (!cancelled) setAttrs(Array.isArray(j.attrs) ? j.attrs : []); })
468
+ .catch(() => {});
469
+ apiFetch(`/api/dsh-mneme/search?q=${encodeURIComponent("entity:" + name)}&limit=10`)
470
+ .then((r) => (r.ok ? r.json() : { items: [] }))
471
+ .then((j) => { if (!cancelled) setRelated(Array.isArray(j.items) ? j.items : []); })
472
+ .catch(() => {});
473
+ return () => { cancelled = true; };
474
+ }, [selected]);
178
475
 
179
- const tabs = ["all", "preference", "project", "decision", "history"];
476
+ // Simulation: run in rAF against posRef, tick React only every few frames.
477
+ // Re-seeds when data changes; dragging writes straight into posRef.
478
+ useEffect(() => {
479
+ if (!data || data.nodes.length === 0) return;
480
+ const width = Math.max(280, svgRef.current?.clientWidth || 380);
481
+ const height = 300;
482
+ posRef.current = initialPositions(data.nodes, width, height);
483
+ const byId = new Map(posRef.current.map((n) => [n.id, n]));
484
+ const edges = data.edges;
485
+ let raf = 0;
486
+ let n = 0;
487
+ const tick = () => {
488
+ const nodes = posRef.current;
489
+ // pairwise repulsion, capped so far nodes don't explode
490
+ for (let i = 0; i < nodes.length; i++) {
491
+ for (let j = i + 1; j < nodes.length; j++) {
492
+ const a = nodes[i], b = nodes[j];
493
+ let dx = b.x - a.x, dy = b.y - a.y;
494
+ let d2 = dx * dx + dy * dy;
495
+ if (d2 < 1) { dx = (Math.random() - 0.5) || 1; dy = (Math.random() - 0.5) || 1; d2 = dx * dx + dy * dy; }
496
+ const d = Math.sqrt(d2);
497
+ const f = Math.min(2200 / d2, 6);
498
+ const fx = (dx / d) * f, fy = (dy / d) * f;
499
+ a.vx -= fx; a.vy -= fy; b.vx += fx; b.vy += fy;
500
+ }
501
+ }
502
+ // edge springs pull toward the target length
503
+ for (const e of edges) {
504
+ const a = byId.get(e.from), b = byId.get(e.to);
505
+ if (!a || !b) continue;
506
+ const dx = b.x - a.x, dy = b.y - a.y;
507
+ const d = Math.sqrt(dx * dx + dy * dy) || 1;
508
+ const f = (d - 90) * 0.02;
509
+ const fx = (dx / d) * f, fy = (dy / d) * f;
510
+ a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
511
+ }
512
+ let energy = 0;
513
+ for (const node of nodes) {
514
+ // gentle gravity toward center keeps the cloud from drifting off-canvas
515
+ node.vx += (width / 2 - node.x) * 0.002;
516
+ node.vy += (height / 2 - node.y) * 0.002;
517
+ if (node.pinned || node === dragRef.current?.node) { node.vx = 0; node.vy = 0; continue; }
518
+ node.vx *= 0.85; node.vy *= 0.85;
519
+ node.x = Math.max(nodeRadius(node) + 4, Math.min(width - nodeRadius(node) - 4, node.x + node.vx));
520
+ node.y = Math.max(nodeRadius(node) + 14, Math.min(height - nodeRadius(node) - 14, node.y + node.vy));
521
+ energy += Math.abs(node.vx) + Math.abs(node.vy);
522
+ }
523
+ n++;
524
+ if (n % 3 === 0) setFrame((f) => f + 1);
525
+ if (n < 300 && energy > 0.4) raf = requestAnimationFrame(tick);
526
+ };
527
+ raf = requestAnimationFrame(tick);
528
+ return () => cancelAnimationFrame(raf);
529
+ }, [data]);
180
530
 
181
- const body = react.createElement("div", { style: embedded ? { width: "100%", display: "flex", flexDirection: "column" } : styles.panel },
182
- !embedded && react.createElement("div", { style: styles.header },
183
- react.createElement("span", { style: styles.title }, t("memory.panel.title")),
184
- react.createElement("button", { style: styles.close, onClick: onClose }, "×")
185
- ),
186
- react.createElement("div", { style: { display: "flex", gap: 6, alignItems: "center", marginBottom: 12 } },
187
- react.createElement("input", {
188
- style: { ...styles.search, flex: 1, marginBottom: 0 },
189
- placeholder: t("memory.panel.search"),
190
- value: query,
191
- onChange: (e) => setQuery(e.target.value)
192
- }),
193
- vecEnabled && react.createElement("button", {
194
- style: { ...styles.tab, ...(semantic ? styles.tabActive : {}) },
195
- title: t("memory.settings.vectorTitle"),
196
- onClick: () => setSemantic(!semantic)
197
- }, t("memory.panel.semantic"))
531
+ // drag handling on the svg surface
532
+ const onNodeMouseDown = (e, node) => {
533
+ e.preventDefault();
534
+ dragRef.current = { node, moved: false };
535
+ const startX = e.clientX, startY = e.clientY;
536
+ const origX = node.x, origY = node.y;
537
+ const svg = svgRef.current;
538
+ const rect = svg.getBoundingClientRect();
539
+ const scale = 380 / Math.max(1, rect.width); // viewBox width / css width
540
+ const onMove = (ev) => {
541
+ dragRef.current.moved = true;
542
+ node.x = origX + (ev.clientX - startX) * scale;
543
+ node.y = origY + (ev.clientY - startY) * scale;
544
+ setFrame((f) => f + 1);
545
+ };
546
+ const onUp = () => {
547
+ window.removeEventListener("mousemove", onMove);
548
+ window.removeEventListener("mouseup", onUp);
549
+ setTimeout(() => { dragRef.current = null; }, 0);
550
+ };
551
+ window.addEventListener("mousemove", onMove);
552
+ window.addEventListener("mouseup", onUp);
553
+ };
554
+
555
+ const onNodeClick = (node) => {
556
+ if (dragRef.current?.moved) return; // it was a drag, not a click
557
+ setSelected({ kind: "node", node });
558
+ };
559
+ const onEdgeClick = (edge) => setSelected({ kind: "edge", edge });
560
+
561
+ const nodes = posRef.current;
562
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
563
+ const VIEW_W = 380, VIEW_H = 300;
564
+
565
+ const side = selected?.kind === "node"
566
+ ? h("div", { className: "mneme-graphside" },
567
+ h("div", { className: "mneme-gs-title" },
568
+ h("span", null, selected.node.name),
569
+ h("span", { className: "mneme-gs-meta" },
570
+ `${typeLabel(t, selected.node.type || "concept")} · ★${selected.node.mention_count ?? 1}`)
198
571
  ),
199
- react.createElement("div", { style: styles.tabs },
200
- tabs.map((key) =>
201
- react.createElement("button", {
202
- key,
203
- style: { ...styles.tab, ...(tab === key ? styles.tabActive : {}) },
204
- onClick: () => setTab(key)
205
- }, t(`memory.tab.${key}`))
206
- )
572
+ attrs.length > 0 && h("div", null,
573
+ h("div", { className: "mneme-gs-meta" }, t("memory.graph.attrs")),
574
+ attrs.map((a, i) => h("div", { key: i, className: "mneme-gs-attr" },
575
+ h("span", { className: "mneme-gs-attrkey" }, `${a.key}:`),
576
+ h("span", { className: "mneme-gs-attrval" }, a.value)
577
+ ))
207
578
  ),
208
- react.createElement("div", { style: styles.list },
209
- loading
210
- ? react.createElement("div", { style: styles.hint }, "…")
211
- : items.length === 0
212
- ? react.createElement("div", { style: styles.hint }, t("memory.panel.empty"))
213
- : items.map((item) =>
214
- react.createElement("div", { key: item.id, style: styles.card },
215
- react.createElement("div", { style: styles.cardTitle },
216
- react.createElement("span", null, item.title),
217
- react.createElement("span", { style: styles.badge },
218
- `${typeLabel(t, item.type)} · ★${item.importance}`
219
- )
220
- ),
221
- react.createElement("div", { style: styles.cardContent }, item.content),
222
- react.createElement("div", { style: styles.cardMeta },
223
- formatDate(item.updated_at)
224
- )
225
- )
226
- )
579
+ related.length > 0 && h("div", { style: { marginTop: 8 } },
580
+ h("div", { className: "mneme-gs-meta" }, t("memory.graph.related")),
581
+ related.map((m) => h("button", {
582
+ key: m.id,
583
+ type: "button",
584
+ className: "mneme-gs-link",
585
+ title: t("memory.card.open"),
586
+ onClick: () => onJumpMemory && onJumpMemory(m)
587
+ }, m.title || m.content?.slice(0, 60)))
227
588
  )
589
+ )
590
+ : selected?.kind === "edge"
591
+ ? h("div", { className: "mneme-graphside" },
592
+ h("div", { className: "mneme-gs-title" },
593
+ h("span", null,
594
+ `${nodeById.get(selected.edge.from)?.name ?? "?"} → ${selected.edge.relation_type} → ${nodeById.get(selected.edge.to)?.name ?? "?"}`)
595
+ ),
596
+ h("div", { className: "mneme-gs-meta" },
597
+ `${t("memory.graph.relation")} · ${formatRelativeTime(selected.edge.created_at, t)}`),
598
+ selected.edge.memory_id && h("button", {
599
+ className: "mneme-footbtn",
600
+ onClick: () => onJumpMemory && onJumpMemory({ id: selected.edge.memory_id })
601
+ }, t("memory.graph.sourceMemory"))
602
+ )
603
+ : null;
604
+
605
+ return h("div", { className: "mneme-graph" },
606
+ h("div", { className: "mneme-graphbar" },
607
+ h("input", {
608
+ className: "mneme-search",
609
+ style: { flex: 1, minWidth: 0, maxWidth: 280 },
610
+ placeholder: t("memory.graph.placeholder"),
611
+ value: inputValue,
612
+ onChange: (e) => setInputValue(e.target.value),
613
+ onKeyDown: (e) => { if (e.key === "Enter") setEntityName(inputValue.trim()); }
614
+ }),
615
+ h("button", {
616
+ className: depth === 2 ? "mneme-chip mneme-active" : "mneme-chip",
617
+ title: t("memory.graph.depth"),
618
+ onClick: () => setDepth(depth === 1 ? 2 : 1)
619
+ }, `${depth} ${t("memory.graph.depth")}`)
620
+ ),
621
+ status === "idle" && h("div", { className: "mneme-hint" }, t("memory.graph.empty")),
622
+ status === "loading" && h("div", { className: "mneme-hint" }, t("memory.graph.loading")),
623
+ status === "notfound" && h("div", { className: "mneme-hint" }, t("memory.graph.notFound")),
624
+ status === "error" && h("div", { className: "mneme-hint" }, t("memory.panel.empty")),
625
+ status === "ready" && (data.nodes.length <= 1
626
+ ? h("div", { className: "mneme-hint" }, t("memory.graph.empty"))
627
+ : h(react.Fragment, null,
628
+ h("svg", {
629
+ ref: svgRef,
630
+ className: "mneme-graphsvg",
631
+ viewBox: `0 0 ${VIEW_W} ${VIEW_H}`
632
+ },
633
+ data.edges.map((e) => {
634
+ const a = nodeById.get(e.from), b = nodeById.get(e.to);
635
+ if (!a || !b) return null;
636
+ return h("line", {
637
+ key: e.id,
638
+ x1: a.x, y1: a.y, x2: b.x, y2: b.y,
639
+ className: e.memory_id ? "mneme-gedge" : "mneme-gedge mneme-gedge-dashed",
640
+ onClick: () => onEdgeClick(e)
641
+ });
642
+ }),
643
+ nodes.map((n) => h("g", {
644
+ key: n.id,
645
+ className: n.id === data.root.id ? "mneme-gnode mneme-groot" : "mneme-gnode",
646
+ transform: `translate(${n.x},${n.y})`,
647
+ onMouseDown: (e) => onNodeMouseDown(e, n),
648
+ onClick: () => onNodeClick(n),
649
+ "data-node": n.name
650
+ },
651
+ h("circle", { r: nodeRadius(n), fill: typeColor(n.type) }),
652
+ h("text", { className: "mneme-glabel", y: nodeRadius(n) + 13 }, n.name)
653
+ ))
654
+ ),
655
+ h("div", { className: "mneme-graphhint" }, t("memory.graph.hint"))
656
+ )),
657
+ side
228
658
  );
229
- return embedded
230
- ? body
231
- : createPortal(react.createElement("div", { style: styles.overlay }, body), document.body);
232
659
  }
233
660
 
234
- const h = react.createElement;
661
+ // --- Settings view: user profile, rules, custom commands, vector, token ---
662
+ const styles = {
663
+ 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" }
664
+ };
235
665
 
236
- // --- Settings panel: user profile, rules, custom commands ---
237
- function SettingsPanel({ t, onClose, embedded }) {
238
- const [sectionTab, setSectionTab] = react.useState("settings");
666
+ function SettingsContent({ t }) {
239
667
  const [profile, setProfile] = react.useState("");
240
668
  const [rules, setRules] = react.useState([]);
241
669
  const [commands, setCommands] = react.useState([]);
@@ -360,142 +788,511 @@ window.__ModuleLoader__.load({
360
788
  } catch { /* ignore */ }
361
789
  }
362
790
 
363
- const inputStyle = { ...styles.search, marginBottom: 8 };
364
- const labelStyle = { fontSize: 12, fontWeight: 600, margin: "12px 0 4px" };
791
+ const inputStyle = { boxSizing: "border-box", width: "100%", height: 34, padding: "0 12px", borderRadius: 10, border: "1px solid var(--dsw-alias-border-l2, #ddd)", background: "var(--dsw-alias-bg-base, transparent)", color: "var(--dsw-alias-label-primary)", fontFamily: "inherit", fontSize: 13, outline: "none", marginBottom: 8 };
792
+ const labelStyle = { fontSize: 12, fontWeight: 600, margin: "12px 0 4px", color: "var(--dsw-alias-label-primary)" };
365
793
  const hintStyle = { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #999)", marginBottom: 8 };
366
794
  const rowStyle = { display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, padding: "6px 0", borderBottom: "1px solid var(--dsw-alias-border-l1, #eee)" };
367
795
 
368
- const body = h("div", { style: embedded ? { width: "100%", display: "flex", flexDirection: "column" } : styles.panel },
369
- !embedded && h("div", { style: styles.header },
370
- h("span", { style: styles.title }, t("memory.settings.title")),
371
- h("button", { style: styles.close, onClick: onClose }, "×")
796
+ return h("div", { style: { paddingBottom: 8 } },
797
+ // api token (optional)
798
+ h("div", { style: { ...labelStyle, marginTop: 20 } }, t("memory.settings.apiTokenTitle")),
799
+ h("div", { style: hintStyle }, t("memory.settings.apiTokenHint")),
800
+ h("div", { style: { display: "flex", gap: 6 } },
801
+ h("input", { style: { ...inputStyle, flex: 1, marginBottom: 0 }, type: "password", value: apiToken, placeholder: t("memory.settings.apiTokenPlaceholder"), onChange: (e) => setApiToken(e.target.value) }),
802
+ h("button", { style: styles.footerButton, onClick: saveToken }, t("memory.settings.apiTokenSave"))
372
803
  ),
373
- h("div", { style: styles.tabs },
374
- h("button", { style: { ...styles.tab, ...(sectionTab === "memory" ? styles.tabActive : {}) }, onClick: () => setSectionTab("memory") }, t("memory.panel.open")),
375
- h("button", { style: { ...styles.tab, ...(sectionTab === "settings" ? styles.tabActive : {}) }, onClick: () => setSectionTab("settings") }, t("memory.settings.title"))
804
+ apiTokenSaved && h("div", { style: { fontSize: 12, color: "var(--dsw-alias-state-success, #2a7)" } }, t("memory.settings.apiTokenSaved")),
805
+ // profile
806
+ h("div", { style: labelStyle }, t("memory.settings.profile")),
807
+ h("div", { style: hintStyle }, t("memory.settings.profileHint")),
808
+ h("textarea", {
809
+ style: { ...inputStyle, minHeight: 72, resize: "vertical", fontFamily: "inherit", padding: "8px 12px", height: "auto" },
810
+ value: profile,
811
+ placeholder: t("memory.settings.profile"),
812
+ onChange: (e) => setProfile(e.target.value)
813
+ }),
814
+ h("div", null,
815
+ h("button", { style: styles.footerButton, onClick: saveProfile }, t("memory.settings.profileSave")),
816
+ saved && h("span", { style: { fontSize: 12, color: "var(--dsw-alias-state-success, #2a7)" } }, t("memory.settings.profileSaved"))
376
817
  ),
377
- sectionTab === "memory"
378
- ? h(MemoryPanel, { t, embedded: true })
379
- : h("div", { style: { overflowY: "auto" } },
380
- // api token (optional)
381
- h("div", { style: { ...labelStyle, marginTop: 20 } }, t("memory.settings.apiTokenTitle")),
382
- h("div", { style: hintStyle }, t("memory.settings.apiTokenHint")),
383
- h("div", { style: { display: "flex", gap: 6 } },
384
- h("input", { style: { ...inputStyle, flex: 1, marginBottom: 0 }, type: "password", value: apiToken, placeholder: t("memory.settings.apiTokenPlaceholder"), onChange: (e) => setApiToken(e.target.value) }),
385
- h("button", { style: styles.footerButton, onClick: saveToken }, t("memory.settings.apiTokenSave"))
386
- ),
387
- apiTokenSaved && h("div", { style: { fontSize: 12, color: "var(--dsw-alias-state-success, #2a7)" } }, t("memory.settings.apiTokenSaved")),
388
- // profile
389
- h("div", { style: labelStyle }, t("memory.settings.profile")),
390
- h("div", { style: hintStyle }, t("memory.settings.profileHint")),
391
- h("textarea", {
392
- style: { ...inputStyle, minHeight: 72, resize: "both", fontFamily: "inherit" },
393
- value: profile,
394
- placeholder: t("memory.settings.profile"),
395
- onChange: (e) => setProfile(e.target.value)
396
- }),
397
- h("div", null,
398
- h("button", { style: styles.footerButton, onClick: saveProfile }, t("memory.settings.profileSave")),
399
- saved && h("span", { style: { fontSize: 12, color: "var(--dsw-alias-state-success, #2a7)" } }, t("memory.settings.profileSaved"))
400
- ),
401
- // rules
402
- h("div", { style: labelStyle }, t("memory.settings.rules")),
403
- h("div", { style: hintStyle }, t("memory.settings.rulesHint")),
404
- rules.length === 0 && h("div", { style: styles.hint }, t("memory.settings.empty")),
405
- rules.map((rule, i) =>
406
- h("div", { key: i, style: rowStyle },
407
- h("span", { style: { fontSize: 13, flex: 1 } }, rule),
408
- h("button", { style: { ...styles.footerButton, color: "var(--dsw-alias-state-error, #c33)" }, onClick: () => removeRule(i) }, "×")
409
- )
410
- ),
411
- h("div", { style: { display: "flex", gap: 6 } },
412
- h("input", {
413
- style: { ...inputStyle, flex: 1, marginBottom: 0 },
414
- value: newRule,
415
- placeholder: t("memory.settings.rulePlaceholder"),
416
- onChange: (e) => setNewRule(e.target.value)
417
- }),
418
- h("button", { style: styles.footerButton, onClick: addRule }, t("memory.settings.ruleAdd"))
419
- ),
420
- // custom commands
421
- h("div", { style: labelStyle }, t("memory.settings.commands")),
422
- h("div", { style: hintStyle }, t("memory.settings.commandsHint")),
423
- commands.length === 0 && h("div", { style: styles.hint }, t("memory.settings.empty")),
424
- commands.map((cmd) =>
425
- h("div", { key: cmd.id, style: rowStyle },
426
- h("div", { style: { flex: 1 } },
427
- h("div", { style: { fontSize: 13, fontWeight: 600 } }, `/${cmd.name}`),
428
- h("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #999)" } }, cmd.description || cmd.instruction)
429
- ),
430
- h("button", { style: { ...styles.footerButton, color: "var(--dsw-alias-state-error, #c33)" }, onClick: () => removeCommand(cmd.id) }, t("memory.settings.cmdDelete"))
431
- )
432
- ),
433
- h("div", { style: { display: "grid", gap: 6, marginTop: 6 } },
434
- h("input", { style: { ...inputStyle, marginBottom: 0 }, value: newCmd.name, placeholder: t("memory.settings.cmdName"), onChange: (e) => setNewCmd({ ...newCmd, name: e.target.value }) }),
435
- h("input", { style: { ...inputStyle, marginBottom: 0 }, value: newCmd.description, placeholder: t("memory.settings.cmdDesc"), onChange: (e) => setNewCmd({ ...newCmd, description: e.target.value }) }),
436
- h("textarea", { style: { ...inputStyle, marginBottom: 0, minHeight: 48, resize: "both", fontFamily: "inherit" }, value: newCmd.instruction, placeholder: t("memory.settings.cmdInstruction"), onChange: (e) => setNewCmd({ ...newCmd, instruction: e.target.value }) }),
437
- h("button", { style: styles.footerButton, onClick: addCommand }, t("memory.settings.cmdAdd")),
438
- cmdError && h("div", { style: { fontSize: 12, color: "var(--dsw-alias-state-error, #c33)" } }, cmdError)
439
- ),
440
- // vector search
441
- h("div", { style: { ...labelStyle, marginTop: 20 } }, t("memory.settings.vectorTitle")),
442
- h("div", { style: hintStyle }, t("memory.settings.vectorHint")),
443
- h("label", { style: { display: "flex", alignItems: "center", gap: 6, marginBottom: 8, fontSize: 13 } },
444
- h("input", { type: "checkbox", checked: !!vector.enabled, onChange: (e) => setVector({ ...vector, enabled: e.target.checked }) }),
445
- h("span", null, t("memory.settings.vectorEnabled"))
446
- ),
447
- h("input", { style: inputStyle, value: vector.baseUrl, placeholder: t("memory.settings.vectorBaseUrl"), onChange: (e) => setVector({ ...vector, baseUrl: e.target.value }) }),
448
- h("input", { style: inputStyle, type: "password", value: vector.apiKey, placeholder: t("memory.settings.vectorApiKey"), onChange: (e) => setVector({ ...vector, apiKey: e.target.value }) }),
449
- h("input", { style: inputStyle, value: vector.model, placeholder: t("memory.settings.vectorModel"), onChange: (e) => setVector({ ...vector, model: e.target.value }) }),
450
- h("div", { style: { display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" } },
451
- h("button", { style: styles.footerButton, onClick: saveVector }, t("memory.settings.vectorSave")),
452
- vectorSaved && h("span", { style: { fontSize: 12, color: "var(--dsw-alias-state-success, #2a7)" } }, t("memory.settings.vectorSaved")),
453
- h("button", { style: styles.footerButton, onClick: reindex, disabled: reindexing }, reindexing ? t("memory.settings.vectorReindexing") : t("memory.settings.vectorReindex")),
454
- reindexMsg && h("span", { style: { fontSize: 12, color: "var(--dsw-alias-label-secondary, #666)" } }, reindexMsg)
455
- )
818
+ // rules
819
+ h("div", { style: labelStyle }, t("memory.settings.rules")),
820
+ h("div", { style: hintStyle }, t("memory.settings.rulesHint")),
821
+ rules.length === 0 && h("div", { style: { fontSize: 12, color: "var(--dsw-alias-label-tertiary, #999)", padding: "8px 0" } }, t("memory.settings.empty")),
822
+ rules.map((rule, i) =>
823
+ h("div", { key: i, style: rowStyle },
824
+ h("span", { style: { fontSize: 13, flex: 1 } }, rule),
825
+ h("button", { style: { ...styles.footerButton, color: "var(--dsw-alias-state-error, #c33)" }, onClick: () => removeRule(i) }, "×")
826
+ )
827
+ ),
828
+ h("div", { style: { display: "flex", gap: 6 } },
829
+ h("input", {
830
+ style: { ...inputStyle, flex: 1, marginBottom: 0 },
831
+ value: newRule,
832
+ placeholder: t("memory.settings.rulePlaceholder"),
833
+ onChange: (e) => setNewRule(e.target.value)
834
+ }),
835
+ h("button", { style: styles.footerButton, onClick: addRule }, t("memory.settings.ruleAdd"))
836
+ ),
837
+ // custom commands
838
+ h("div", { style: labelStyle }, t("memory.settings.commands")),
839
+ h("div", { style: hintStyle }, t("memory.settings.commandsHint")),
840
+ commands.length === 0 && h("div", { style: { fontSize: 12, color: "var(--dsw-alias-label-tertiary, #999)", padding: "8px 0" } }, t("memory.settings.empty")),
841
+ commands.map((cmd) =>
842
+ h("div", { key: cmd.id, style: rowStyle },
843
+ h("div", { style: { flex: 1 } },
844
+ h("div", { style: { fontSize: 13, fontWeight: 600 } }, `/${cmd.name}`),
845
+ h("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #999)" } }, cmd.description || cmd.instruction)
846
+ ),
847
+ h("button", { style: { ...styles.footerButton, color: "var(--dsw-alias-state-error, #c33)" }, onClick: () => removeCommand(cmd.id) }, t("memory.settings.cmdDelete"))
848
+ )
849
+ ),
850
+ h("div", { style: { display: "grid", gap: 6, marginTop: 6 } },
851
+ h("input", { style: { ...inputStyle, marginBottom: 0 }, value: newCmd.name, placeholder: t("memory.settings.cmdName"), onChange: (e) => setNewCmd({ ...newCmd, name: e.target.value }) }),
852
+ h("input", { style: { ...inputStyle, marginBottom: 0 }, value: newCmd.description, placeholder: t("memory.settings.cmdDesc"), onChange: (e) => setNewCmd({ ...newCmd, description: e.target.value }) }),
853
+ h("textarea", { style: { ...inputStyle, marginBottom: 0, minHeight: 48, resize: "vertical", fontFamily: "inherit", padding: "8px 12px", height: "auto" }, value: newCmd.instruction, placeholder: t("memory.settings.cmdInstruction"), onChange: (e) => setNewCmd({ ...newCmd, instruction: e.target.value }) }),
854
+ h("button", { style: styles.footerButton, onClick: addCommand }, t("memory.settings.cmdAdd")),
855
+ cmdError && h("div", { style: { fontSize: 12, color: "var(--dsw-alias-state-error, #c33)" } }, cmdError)
856
+ ),
857
+ // vector search
858
+ h("div", { style: { ...labelStyle, marginTop: 20 } }, t("memory.settings.vectorTitle")),
859
+ h("div", { style: hintStyle }, t("memory.settings.vectorHint")),
860
+ h("label", { style: { display: "flex", alignItems: "center", gap: 6, marginBottom: 8, fontSize: 13 } },
861
+ h("input", { type: "checkbox", checked: !!vector.enabled, onChange: (e) => setVector({ ...vector, enabled: e.target.checked }) }),
862
+ h("span", null, t("memory.settings.vectorEnabled"))
863
+ ),
864
+ h("input", { style: inputStyle, value: vector.baseUrl, placeholder: t("memory.settings.vectorBaseUrl"), onChange: (e) => setVector({ ...vector, baseUrl: e.target.value }) }),
865
+ h("input", { style: inputStyle, type: "password", value: vector.apiKey, placeholder: t("memory.settings.vectorApiKey"), onChange: (e) => setVector({ ...vector, apiKey: e.target.value }) }),
866
+ h("input", { style: inputStyle, value: vector.model, placeholder: t("memory.settings.vectorModel"), onChange: (e) => setVector({ ...vector, model: e.target.value }) }),
867
+ h("div", { style: { display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" } },
868
+ h("button", { style: styles.footerButton, onClick: saveVector }, t("memory.settings.vectorSave")),
869
+ vectorSaved && h("span", { style: { fontSize: 12, color: "var(--dsw-alias-state-success, #2a7)" } }, t("memory.settings.vectorSaved")),
870
+ h("button", { style: styles.footerButton, onClick: reindex, disabled: reindexing }, reindexing ? t("memory.settings.vectorReindexing") : t("memory.settings.vectorReindex")),
871
+ reindexMsg && h("span", { style: { fontSize: 12, color: "var(--dsw-alias-label-secondary, #666)" } }, reindexMsg)
872
+ )
873
+ );
874
+ }
875
+
876
+ // --- Main-area memory library: the single home for every memory feature ---
877
+ // Registered under conversation.view beside Chat / Trajectory. The old
878
+ // right-hand drawer is gone: the sidebar foot entry activates this tab
879
+ // directly. The framework keeps the active-view setter private to the
880
+ // conversation package, and the DOM tab click is the one stable
881
+ // activation path available to plugins.
882
+ //
883
+ // One host constraint remains: the conversation header (and with it the
884
+ // whole tab ring) is hidden while a session is blank the new-chat hero
885
+ // screen. There the tab simply does not exist, so activateExplorerTab
886
+ // resolves false and the sidebar entry falls back to the full-viewport
887
+ // overlay surface below.
888
+ function findExplorerTab(label) {
889
+ const tabs = document.querySelectorAll('[role="tab"]');
890
+ for (const tab of tabs) {
891
+ if ((tab.textContent || "").trim() === label) return tab;
892
+ }
893
+ return null;
894
+ }
895
+
896
+ // Click the memory library tab and wait (bounded, rAF-polled) until the
897
+ // host actually marks it selected — a silent React re-render gap must not
898
+ // be mistaken for success, or the fallback would never kick in.
899
+ function activateExplorerTab(label) {
900
+ return new Promise((resolve) => {
901
+ const tab = findExplorerTab(label);
902
+ if (!tab) { resolve(false); return; }
903
+ tab.click();
904
+ const deadline = Date.now() + 400;
905
+ (function check() {
906
+ if (tab.getAttribute("aria-selected") === "true") { resolve(true); return; }
907
+ if (Date.now() >= deadline) { resolve(false); return; }
908
+ requestAnimationFrame(check);
909
+ })();
910
+ });
911
+ }
912
+
913
+ // --- Hero fallback overlay state (module-level pub/sub) ---
914
+ const overlayListeners = new Set();
915
+ let overlayOpenState = false;
916
+ function setOverlayOpen(v) {
917
+ if (v === overlayOpenState) return;
918
+ overlayOpenState = v;
919
+ for (const fn of overlayListeners) fn();
920
+ }
921
+ function useOverlayOpen() {
922
+ const [open, setOpen] = useState(overlayOpenState);
923
+ useEffect(() => {
924
+ const fn = () => setOpen(overlayOpenState);
925
+ overlayListeners.add(fn);
926
+ return () => { overlayListeners.delete(fn); };
927
+ }, []);
928
+ return [open, setOverlayOpen];
929
+ }
930
+
931
+ // Full-viewport memory library for states without a tab ring (new-chat
932
+ // hero). Same MemoryExplorer component as the tab view — identical
933
+ // three-column layout, graph and settings — plus a slim top bar with a
934
+ // close affordance. Esc closes. Portalled to <body> so sidebar stacking
935
+ // contexts cannot clip it.
936
+ function MemoryOverlay({ t }) {
937
+ const [open, setOpen] = useOverlayOpen();
938
+ useEffect(() => {
939
+ if (!open) return undefined;
940
+ const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
941
+ window.addEventListener("keydown", onKey);
942
+ return () => window.removeEventListener("keydown", onKey);
943
+ }, [open]);
944
+ if (!open) return null;
945
+ const tree = h("div", { className: "mneme-overlay", role: "region", "aria-label": t("memory.view.label") },
946
+ h("div", { className: "mneme-overlaybar" },
947
+ h("span", { className: "mneme-overlaytitle" }, t("memory.view.label")),
948
+ h("button", {
949
+ type: "button",
950
+ className: "mneme-footbtn",
951
+ onClick: () => setOpen(false)
952
+ }, t("memory.overlay.close"))
953
+ ),
954
+ h("div", { className: "mneme-overlaybody" }, h(MemoryExplorer, { t }))
955
+ );
956
+ if (reactDom && typeof document !== "undefined") return reactDom.createPortal(tree, document.body);
957
+ return tree;
958
+ }
959
+
960
+ const EXPLORER_TYPES = ["preference", "project", "decision", "summary", "history"];
961
+
962
+ function MemoryExplorer({ t }) {
963
+ const [view, setView] = useState("memory"); // memory | graph | settings
964
+ const [items, setItems] = useState([]);
965
+ const [loading, setLoading] = useState(true);
966
+ const [type, setType] = useState("all");
967
+ const [query, setQuery] = useState("");
968
+ const [semantic, setSemantic] = useState(false);
969
+ const [vecEnabled, setVecEnabled] = useState(false);
970
+ const [searchTopK, setSearchTopK] = useState(20);
971
+ const [remoteItems, setRemoteItems] = useState(null);
972
+ const [selectedId, setSelectedId] = useState(null);
973
+ const [collapsed, setCollapsed] = useState({});
974
+ const [copied, setCopied] = useState(false);
975
+ const [reloadKey, setReloadKey] = useState(0);
976
+ const [graphFocus, setGraphFocus] = useState("");
977
+ const itemRefs = useRef(new Map());
978
+
979
+ useEffect(() => {
980
+ apiFetch("/api/dsh-mneme/vector-config")
981
+ .then((res) => res.json())
982
+ .then((d) => setVecEnabled(!!d.config?.enabled))
983
+ .catch(() => {});
984
+ }, []);
985
+
986
+ useEffect(() => {
987
+ let cancelled = false;
988
+ setLoading(true);
989
+ apiFetch("/api/dsh-mneme/list?limit=500")
990
+ .then((res) => (res.ok ? res.json() : { items: [] }))
991
+ .then((d) => { if (!cancelled) { setItems(d.items || []); setLoading(false); } })
992
+ .catch(() => { if (!cancelled) { setItems([]); setLoading(false); } });
993
+ return () => { cancelled = true; };
994
+ }, [reloadKey]);
995
+
996
+ // Semantic search: server-side ranking replaces the client filter
997
+ // while enabled and a query is present (debounced).
998
+ useEffect(() => {
999
+ const q = query.trim();
1000
+ if (!semantic || !q || q.startsWith("entity:")) { setRemoteItems(null); return; }
1001
+ let cancelled = false;
1002
+ const timer = setTimeout(() => {
1003
+ apiFetch(`/api/dsh-mneme/search?q=${encodeURIComponent(q)}&mode=vector&topK=${searchTopK}`)
1004
+ .then((res) => (res.ok ? res.json() : { items: [] }))
1005
+ .then((d) => { if (!cancelled) setRemoteItems(d.items || []); })
1006
+ .catch(() => { if (!cancelled) setRemoteItems([]); });
1007
+ }, 250);
1008
+ return () => { cancelled = true; clearTimeout(timer); };
1009
+ }, [semantic, query, searchTopK]);
1010
+
1011
+ useEffect(() => {
1012
+ if (!selectedId) return;
1013
+ itemRefs.current.get(selectedId)?.scrollIntoView({ block: "nearest" });
1014
+ }, [selectedId]);
1015
+
1016
+ const q = query.trim().toLowerCase();
1017
+ // "entity:" is the graph entry grammar: typing it means the user wants
1018
+ // the entity's neighborhood, not a memory list. Offer the jump instead
1019
+ // of auto-switching so the list stays predictable.
1020
+ const entityQuery = query.trim().startsWith("entity:")
1021
+ ? query.trim().slice(7).trim()
1022
+ : "";
1023
+ const visible = remoteItems
1024
+ ? remoteItems
1025
+ : items.filter((m) => {
1026
+ if (type !== "all" && m.type !== type) return false;
1027
+ if (!q) return true;
1028
+ return (m.title || "").toLowerCase().includes(q) || (m.content || "").toLowerCase().includes(q);
1029
+ });
1030
+
1031
+ const counts = {};
1032
+ for (const m of items) counts[m.type] = (counts[m.type] || 0) + 1;
1033
+ const knownTypes = EXPLORER_TYPES.filter((k) => counts[k]);
1034
+ const extraTypes = Object.keys(counts)
1035
+ .filter((k) => !EXPLORER_TYPES.includes(k))
1036
+ .sort((a, b) => counts[b] - counts[a]);
1037
+
1038
+ // Time tree: sort newest first, then group month → day in one pass so
1039
+ // the grouping follows the sort order instead of re-sorting buckets.
1040
+ const sorted = [...visible].sort((a, b) =>
1041
+ new Date(b.updated_at || b.created_at || 0) - new Date(a.updated_at || a.created_at || 0));
1042
+ const months = [];
1043
+ let curMonth = null, curDay = null;
1044
+ for (const m of sorted) {
1045
+ const d = new Date(m.updated_at || m.created_at || 0);
1046
+ const valid = !Number.isNaN(d.getTime());
1047
+ const mk = valid ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}` : "unknown";
1048
+ if (!curMonth || curMonth.key !== mk) {
1049
+ curMonth = {
1050
+ key: mk,
1051
+ label: valid ? d.toLocaleDateString(undefined, { year: "numeric", month: "long" }) : "—",
1052
+ days: []
1053
+ };
1054
+ months.push(curMonth);
1055
+ curDay = null;
1056
+ }
1057
+ const dk = valid ? `${mk}-${String(d.getDate())}` : "unknown";
1058
+ if (!curDay || curDay.key !== dk) {
1059
+ curDay = { key: dk, label: valid ? d.toLocaleDateString(undefined, { day: "numeric" }) : "—", items: [] };
1060
+ curMonth.days.push(curDay);
1061
+ }
1062
+ curDay.items.push(m);
1063
+ }
1064
+
1065
+ const selected = items.find((m) => m.id === selectedId) || null;
1066
+
1067
+ const copyContent = () => {
1068
+ if (!selected) return;
1069
+ navigator.clipboard?.writeText(selected.content || "").then(
1070
+ () => { setCopied(true); setTimeout(() => setCopied(false), 1500); },
1071
+ () => {}
1072
+ );
1073
+ };
1074
+
1075
+ // Graph → memory jump: land on the browser tab with filters reset so
1076
+ // the target row is visible, selected and scrolled into view.
1077
+ const jumpToMemory = (target) => {
1078
+ if (!target?.id) return;
1079
+ setView("memory");
1080
+ setType("all");
1081
+ setQuery("");
1082
+ setCollapsed({});
1083
+ setSelectedId(target.id);
1084
+ };
1085
+
1086
+ const openGraphFor = (name) => {
1087
+ setGraphFocus(name || "");
1088
+ setView("graph");
1089
+ };
1090
+
1091
+ const subviews = [
1092
+ { key: "memory", label: t("memory.explorer.tabMemory") },
1093
+ { key: "graph", label: t("memory.explorer.tabGraph") },
1094
+ { key: "settings", label: t("memory.explorer.tabSettings") }
1095
+ ];
1096
+
1097
+ return h("div", { className: "mneme-x" },
1098
+ h("div", { className: "mneme-xbar" },
1099
+ h("nav", { className: "mneme-vtabs", "aria-label": t("memory.view.label") },
1100
+ subviews.map((s) =>
1101
+ h("button", {
1102
+ key: s.key,
1103
+ type: "button",
1104
+ className: view === s.key ? "mneme-vtab mneme-active" : "mneme-vtab",
1105
+ "aria-pressed": String(view === s.key),
1106
+ onClick: () => setView(s.key)
1107
+ },
1108
+ s.key === "graph"
1109
+ ? h(react.Fragment, null, h(GraphNodesIcon, { size: 16 }), s.label)
1110
+ : s.label
1111
+ ))
1112
+ ),
1113
+ ),
1114
+ view === "memory" && h("div", { className: "mneme-xmain" },
1115
+ h("div", { className: "mneme-xside" },
1116
+ h("div", { className: "mneme-xcolhead" }, t("memory.explorer.searchTitle")),
1117
+ h("input", {
1118
+ className: "mneme-search mneme-xsearch",
1119
+ placeholder: t("memory.explorer.search"),
1120
+ value: query,
1121
+ onChange: (e) => setQuery(e.target.value)
1122
+ }),
1123
+ entityQuery && h("button", {
1124
+ className: "mneme-entitychip",
1125
+ style: { textAlign: "left", justifyContent: "flex-start" },
1126
+ onClick: () => openGraphFor(entityQuery)
1127
+ }, `${t("memory.graph.viewInGraph")} “${entityQuery}”`),
1128
+ h("div", { className: "mneme-xrow" },
1129
+ vecEnabled && h("button", {
1130
+ className: semantic ? "mneme-chip mneme-active" : "mneme-chip",
1131
+ title: t("memory.settings.vectorTitle"),
1132
+ onClick: () => setSemantic(!semantic)
1133
+ }, t("memory.panel.semantic")),
1134
+ h("select", {
1135
+ className: "mneme-select mneme-xselect",
1136
+ value: searchTopK,
1137
+ onChange: (e) => setSearchTopK(Number(e.target.value)),
1138
+ title: t("memory.explorer.topK")
1139
+ }, [5, 10, 20, 50].map((n) => h("option", { key: n, value: n }, t("memory.explorer.topKOption").replace("{n}", String(n)))))
1140
+ ),
1141
+ h("div", { className: "mneme-xrow" },
1142
+ h("span", { className: "mneme-xcount" }, t("memory.explorer.count").replace("{n}", String(visible.length))),
1143
+ h("button", { className: "mneme-footbtn", onClick: () => setReloadKey((k) => k + 1) }, t("memory.explorer.refresh"))
456
1144
  )
1145
+ ),
1146
+ h("div", { className: "mneme-xside mneme-xside--filter" },
1147
+ h("div", { className: "mneme-xcolhead" }, t("memory.explorer.types")),
1148
+ h("button", {
1149
+ className: type === "all" ? "mneme-xtype mneme-active" : "mneme-xtype",
1150
+ onClick: () => setType("all")
1151
+ }, h("span", null, t("memory.tab.all")), h("span", { className: "mneme-xcount2" }, String(items.length))),
1152
+ knownTypes.concat(extraTypes).map((key) =>
1153
+ h("button", {
1154
+ key,
1155
+ className: type === key ? "mneme-xtype mneme-active" : "mneme-xtype",
1156
+ onClick: () => setType(key)
1157
+ },
1158
+ h("span", null, typeLabel(t, key)),
1159
+ h("span", { className: "mneme-xcount2" }, String(counts[key]))
1160
+ ))
1161
+ ),
1162
+ h("div", { className: "mneme-xbrowse" },
1163
+ h("div", { className: "mneme-xdetail" },
1164
+ selected
1165
+ ? h(react.Fragment, { key: selected.id },
1166
+ h("div", { className: "mneme-xdinner" },
1167
+ h("div", { className: "mneme-xdtitle" }, selected.title),
1168
+ h("div", { className: "mneme-xdmeta" },
1169
+ h("span", null, `${typeLabel(t, selected.type)} · ${t("memory.explorer.importance")} ★${selected.importance}`),
1170
+ selected.source && h("span", null, `${t("memory.explorer.source")}: ${selected.source}`),
1171
+ h("span", { title: formatDate(selected.created_at) }, `${t("memory.explorer.created")}: ${formatDate(selected.created_at)}`),
1172
+ h("span", { title: formatDate(selected.updated_at) }, `${t("memory.explorer.updated")}: ${formatDate(selected.updated_at)}`)
1173
+ ),
1174
+ Array.isArray(selected.tags) && selected.tags.length > 0 && h("div", { className: "mneme-xdmeta" },
1175
+ h("span", null, `${t("memory.explorer.tags")}: ${selected.tags.join(" · ")}`)
1176
+ ),
1177
+ h("div", { className: "mneme-xdcontent" }, selected.content),
1178
+ h("div", { className: "mneme-xdactions" },
1179
+ h("button", { className: "mneme-footbtn", onClick: copyContent },
1180
+ copied ? t("memory.explorer.copied") : t("memory.explorer.copy"))
1181
+ )
1182
+ )
1183
+ )
1184
+ : h("div", { className: "mneme-xempty" }, t("memory.explorer.emptyDetail"))
1185
+ ),
1186
+ h("div", { className: "mneme-xtree" },
1187
+ h("div", { className: "mneme-xcolhead" }, t("memory.explorer.timeline")),
1188
+ loading
1189
+ ? h("div", { className: "mneme-xempty" }, "…")
1190
+ : months.length === 0
1191
+ ? h("div", { className: "mneme-xempty" }, t("memory.explorer.empty"))
1192
+ : months.map((month) =>
1193
+ h("div", { key: month.key },
1194
+ h("button", {
1195
+ className: "mneme-xmonth",
1196
+ "aria-expanded": String(!collapsed[month.key]),
1197
+ onClick: () => setCollapsed((c) => ({ ...c, [month.key]: !c[month.key] }))
1198
+ },
1199
+ h("span", { className: "mneme-xcaret" }, collapsed[month.key] ? "▸" : "▾"),
1200
+ month.label
1201
+ ),
1202
+ !collapsed[month.key] && month.days.map((day) =>
1203
+ h("div", { key: day.key },
1204
+ h("button", {
1205
+ className: "mneme-xday",
1206
+ "aria-expanded": String(!collapsed[day.key]),
1207
+ onClick: () => setCollapsed((c) => ({ ...c, [day.key]: !c[day.key] }))
1208
+ },
1209
+ h("span", { className: "mneme-xcaret" }, collapsed[day.key] ? "▸" : "▾"),
1210
+ day.label
1211
+ ),
1212
+ !collapsed[day.key] && day.items.map((m) => {
1213
+ const d = new Date(m.updated_at || m.created_at || 0);
1214
+ const time = Number.isNaN(d.getTime())
1215
+ ? ""
1216
+ : d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
1217
+ return h("button", {
1218
+ key: m.id,
1219
+ ref: (el) => { if (el) itemRefs.current.set(m.id, el); else itemRefs.current.delete(m.id); },
1220
+ className: m.id === selectedId ? "mneme-xitem mneme-active" : "mneme-xitem",
1221
+ onClick: () => setSelectedId(m.id)
1222
+ },
1223
+ h("span", { className: "mneme-xtime" }, time),
1224
+ h("span", { className: "mneme-xname" }, m.title || m.content?.slice(0, 40))
1225
+ );
1226
+ })
1227
+ ))
1228
+ ))
1229
+ )
1230
+ )
1231
+ ),
1232
+ view === "graph" && h(GraphPanel, { t, focusEntity: graphFocus, onJumpMemory: jumpToMemory }),
1233
+ view === "settings" && h("div", { className: "mneme-xsettings" },
1234
+ h("div", { className: "mneme-xsettings-inner" }, h(SettingsContent, { t }))
1235
+ )
457
1236
  );
458
- return embedded
459
- ? body
460
- : createPortal(h("div", { style: styles.overlay }, body), document.body);
461
1237
  }
462
1238
 
463
- const styles = {
464
- overlay: { position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", zIndex: 1000, display: "flex", alignItems: "center", justifyContent: "center" },
465
- panel: { background: "var(--dsw-alias-bg-base, #fff)", borderRadius: 12, width: 640, maxWidth: "90vw", maxHeight: "80vh", display: "flex", flexDirection: "column", padding: 16, boxShadow: "0 8px 40px rgba(0,0,0,0.2)" },
466
- header: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 },
467
- title: { fontSize: 16, fontWeight: 600 },
468
- close: { border: "none", background: "none", fontSize: 20, cursor: "pointer", color: "var(--dsw-alias-label-secondary, #666)" },
469
- search: { padding: "8px 12px", borderRadius: 8, border: "1px solid var(--dsw-alias-border-l2, #ddd)", marginBottom: 12, fontSize: 14 },
470
- tabs: { display: "flex", gap: 6, marginBottom: 12, flexWrap: "wrap" },
471
- tab: { padding: "4px 10px", borderRadius: 999, border: "1px solid var(--dsw-alias-border-l2, #ddd)", background: "none", cursor: "pointer", fontSize: 12 },
472
- tabActive: { background: "var(--dsw-alias-interactive-bg-active, #eee)" },
473
- list: { overflowY: "auto", display: "flex", flexDirection: "column", gap: 8 },
474
- hint: { color: "var(--dsw-alias-label-tertiary, #999)", padding: "24px 0", textAlign: "center" },
475
- card: { border: "1px solid var(--dsw-alias-border-l1, #eee)", borderRadius: 8, padding: "10px 12px" },
476
- cardTitle: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4, fontSize: 14, fontWeight: 600 },
477
- badge: { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #999)" },
478
- cardContent: { fontSize: 13, color: "var(--dsw-alias-label-secondary, #555)", marginBottom: 4, whiteSpace: "pre-wrap", wordBreak: "break-word" },
479
- cardMeta: { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #999)" },
480
- footerButton: { padding: "4px 10px", borderRadius: 8, border: "1px solid var(--dsw-alias-border-l2, #ddd)", background: "none", cursor: "pointer", fontSize: 12, margin: "2px 8px" },
481
- footerButtonActive: { background: "var(--dsw-alias-interactive-bg-active, #eee)" }
482
- };
1239
+ // --- Sidebar foot entry: the wide row / rail icon that activates the
1240
+ // main-area memory library tab. When the tab ring is absent (new-chat
1241
+ // hero screen hides the conversation header entirely) the same click
1242
+ // opens the full-viewport overlay instead the library stays reachable
1243
+ // from every conversation state.
1244
+ function SidebarTrigger({ wide, t }) {
1245
+ const [, setOpen] = useOverlayOpen();
1246
+ return h("button", {
1247
+ type: "button",
1248
+ className: wide ? "mneme-trigger" : "mneme-trigger mneme-rail",
1249
+ "aria-label": t("memory.sidebar.aria"),
1250
+ title: t("memory.sidebar.aria"),
1251
+ onClick: () => {
1252
+ activateExplorerTab(t("memory.view.label")).then((ok) => { if (!ok) setOpen(true); });
1253
+ }
1254
+ },
1255
+ h(IconArchiveOutline20, { size: wide ? 16 : 18 }),
1256
+ wide && h("span", { className: "mneme-trigger-label" }, t("memory.panel.open"))
1257
+ );
1258
+ }
483
1259
 
484
1260
  function apply(ctx) {
485
1261
  ctx.effect(() => ctx.locale.register(NS, dictionaries), "dsh-mneme: dictionaries");
486
1262
 
487
- // Register a custom section inside the DSH settings panel (nav label +
488
- // embedded memory/settings content). Access is via the official settings
489
- // panel only; no sidebar buttons are registered.
490
- ctx.effect(() => {
1263
+ // Register the memory entry beside Settings at the sidebar foot. The
1264
+ // entry renders a wide row (icon + label) when the sidebar is expanded
1265
+ // and a rail icon when collapsed; clicking activates the memory
1266
+ // library conversation view.
1267
+ ctx.slots.inject("sidebar.footer.action", () => {
491
1268
  const t = ctx.locale.bind(NS);
492
1269
  return ctx.slots.register({
493
- name: "settings.section",
1270
+ name: "sidebar.footer.action",
494
1271
  id: "dsh-mneme",
495
- label: () => t("memory.settings.title"),
496
- inject: () => ({})
497
- }, () => react.createElement(SettingsPanel, { t, embedded: true, onClose: () => {} }));
498
- }, "dsh-mneme: settings section");
1272
+ order: 0,
1273
+ label: () => t("memory.panel.open")
1274
+ }, (props) => h(react.Fragment, null,
1275
+ h(SidebarTrigger, { ...props, t }),
1276
+ // The overlay mounts from the always-rendered sidebar slot so it
1277
+ // survives conversation switches; the portal moves it to <body>.
1278
+ h(MemoryOverlay, { t })
1279
+ ));
1280
+ });
1281
+
1282
+ // Register the memory library as a conversation view tab, beside
1283
+ // Chat / Trajectory. The view ignores its session props: the library
1284
+ // reads the memory store over HTTP. It hosts every memory feature —
1285
+ // browse, graph and settings — in the main content area.
1286
+ ctx.slots.inject("conversation.view", () => {
1287
+ const t = ctx.locale.bind(NS);
1288
+ return ctx.slots.register({
1289
+ name: "conversation.view",
1290
+ id: "dsh-mneme-memory",
1291
+ order: 30,
1292
+ locale: NS,
1293
+ label: () => t("memory.view.label")
1294
+ }, () => h(MemoryExplorer, { t }));
1295
+ });
499
1296
  }
500
1297
 
501
1298
  exports.apply = apply;