@modusensus/dsh-mneme 0.6.0 → 0.6.5

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
@@ -88,6 +88,7 @@ window.__ModuleLoader__.load({
88
88
  "memory.overlay.close": "关闭",
89
89
  "memory.explorer.tabMemory": "记忆",
90
90
  "memory.explorer.tabGraph": "图谱",
91
+ "memory.explorer.tabDirectory": "目录",
91
92
  "memory.explorer.tabSettings": "设置",
92
93
  "memory.explorer.search": "搜索标题或内容…",
93
94
  "memory.explorer.searchTitle": "语义检索",
@@ -104,9 +105,16 @@ window.__ModuleLoader__.load({
104
105
  "memory.explorer.created": "创建",
105
106
  "memory.explorer.updated": "更新",
106
107
  "memory.explorer.tags": "标签",
108
+ "memory.explorer.tagAdd": "添加标签",
109
+ "memory.explorer.tagRemove": "移除标签",
110
+ "memory.explorer.tagPlaceholder": "输入标签,回车添加",
111
+ "memory.explorer.tagsEmpty": "暂无标签",
107
112
  "memory.explorer.importance": "重要性",
108
113
  "memory.explorer.topK": "返回数量",
109
114
  "memory.explorer.topKOption": "返回 {n} 条",
115
+ "memory.directory.untagged": "无标签",
116
+ "memory.directory.loading": "加载中…",
117
+ "memory.directory.empty": "暂无记忆条目",
110
118
  "memory.card.open": "在主区记忆库中查看全文",
111
119
  "memory.time.now": "刚刚",
112
120
  "memory.time.seconds": "{n}秒前",
@@ -142,7 +150,12 @@ window.__ModuleLoader__.load({
142
150
  "memory.settings.apiTokenHint": "设置后,写操作与密钥接口(画像/规则/命令/向量配置)需携带 Authorization: Bearer <token>;面板只读操作不受影响。清空并保存可关闭鉴权。",
143
151
  "memory.settings.apiTokenPlaceholder": "留空 = 不鉴权(默认)",
144
152
  "memory.settings.apiTokenSave": "保存 Token",
145
- "memory.settings.apiTokenSaved": "Token 已保存"
153
+ "memory.settings.apiTokenSaved": "Token 已保存",
154
+ "memory.wikilink.backlinks": "链接到此记忆",
155
+ "memory.wikilink.forward": "此记忆链接到",
156
+ "memory.wikilink.empty": "暂无关联记忆",
157
+ "memory.wikilink.loading": "加载中…",
158
+ "memory.wikilink.unresolved": "目标记忆不存在"
146
159
  },
147
160
  en: {
148
161
  "memory.panel.empty": "No memories yet",
@@ -175,6 +188,7 @@ window.__ModuleLoader__.load({
175
188
  "memory.overlay.close": "Close",
176
189
  "memory.explorer.tabMemory": "Memories",
177
190
  "memory.explorer.tabGraph": "Graph",
191
+ "memory.explorer.tabDirectory": "Directory",
178
192
  "memory.explorer.tabSettings": "Settings",
179
193
  "memory.explorer.search": "Search title or content…",
180
194
  "memory.explorer.searchTitle": "Semantic Search",
@@ -191,9 +205,16 @@ window.__ModuleLoader__.load({
191
205
  "memory.explorer.created": "Created",
192
206
  "memory.explorer.updated": "Updated",
193
207
  "memory.explorer.tags": "Tags",
208
+ "memory.explorer.tagAdd": "Add tag",
209
+ "memory.explorer.tagRemove": "Remove tag",
210
+ "memory.explorer.tagPlaceholder": "Type a tag, press Enter",
211
+ "memory.explorer.tagsEmpty": "No tags",
194
212
  "memory.explorer.importance": "Importance",
195
213
  "memory.explorer.topK": "Results limit",
196
214
  "memory.explorer.topKOption": "Return {n}",
215
+ "memory.directory.untagged": "Untagged",
216
+ "memory.directory.loading": "Loading…",
217
+ "memory.directory.empty": "No memories yet",
197
218
  "memory.card.open": "Open full text in the Memory tab",
198
219
  "memory.time.now": "just now",
199
220
  "memory.time.seconds": "{n}s ago",
@@ -229,7 +250,12 @@ window.__ModuleLoader__.load({
229
250
  "memory.settings.apiTokenHint": "When set, write operations and secret endpoints (profile/rules/commands/vector config) require Authorization: Bearer <token>. Read-only panel calls stay open. Save empty to disable.",
230
251
  "memory.settings.apiTokenPlaceholder": "Empty = no auth (default)",
231
252
  "memory.settings.apiTokenSave": "Save Token",
232
- "memory.settings.apiTokenSaved": "Token saved"
253
+ "memory.settings.apiTokenSaved": "Token saved",
254
+ "memory.wikilink.backlinks": "Links to this memory",
255
+ "memory.wikilink.forward": "This memory links to",
256
+ "memory.wikilink.empty": "No linked memories",
257
+ "memory.wikilink.loading": "Loading…",
258
+ "memory.wikilink.unresolved": "Target memory not found"
233
259
  }
234
260
  };
235
261
 
@@ -289,6 +315,17 @@ window.__ModuleLoader__.load({
289
315
  ".mneme-hint{color:var(--dsw-alias-label-tertiary);padding:24px 0;text-align:center;font-size:13px}",
290
316
  ".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
317
  ".mneme-entitychip:hover{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent)}",
318
+ // --- tag chips in the detail meta (v0.6.2) ---
319
+ ".mneme-tagrow{display:flex;flex-wrap:wrap;gap:6px;align-items:center}",
320
+ ".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}",
321
+ ".mneme-tagchip:hover{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent)}",
322
+ ".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}",
323
+ ".mneme-tagremove:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}",
324
+ ".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}",
325
+ ".mneme-tagadd:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
326
+ ".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}",
327
+ ".mneme-taginput:focus{border-color:var(--dsw-alias-state-business-primary)}",
328
+ ".mneme-tagempty{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px}",
292
329
  // --- main-area memory library page ---
293
330
  ".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
331
  ".mneme-xbar{flex:none;display:flex;align-items:center;gap:6px;border-bottom:1px solid var(--dsw-alias-border-l2);padding:0 16px}",
@@ -333,6 +370,21 @@ window.__ModuleLoader__.load({
333
370
  ".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
371
  ".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
372
  ".mneme-xdactions{display:flex;gap:8px;margin-top:18px}",
373
+ // --- wiki-link backlinks panel (detail pane footer) ---
374
+ ".mneme-backlinks{margin-top:18px;padding-top:14px;border-top:1px solid var(--dsw-alias-border-l1);display:flex;flex-direction:column;gap:12px}",
375
+ ".mneme-bl-block{display:flex;flex-direction:column;gap:4px}",
376
+ ".mneme-bl-head{font-size:12px;font-weight:500;color:var(--dsw-alias-label-tertiary)}",
377
+ ".mneme-bl-list{display:flex;flex-wrap:wrap;gap:6px}",
378
+ ".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}",
379
+ ".mneme-bl-link:hover{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent)}",
380
+ ".mneme-bl-link.mneme-bl-link--dim{color:var(--dsw-alias-label-tertiary);cursor:default}",
381
+ ".mneme-bl-link.mneme-bl-link--dim:hover{background:none}",
382
+ ".mneme-bl-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
383
+ ".mneme-bl-meta{flex:none;font-size:11px;color:var(--dsw-alias-label-tertiary)}",
384
+ ".mneme-bl-empty{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}",
385
+ // --- inline [[wiki-link]] links inside the detail content ---
386
+ ".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}",
387
+ ".mneme-wikilink:hover{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 8%,transparent)}",
336
388
  // --- graph sub-view (fills the content area under the tabs) ---
337
389
  ".mneme-graph{flex:1;min-height:0;width:100%;display:flex;flex-direction:column;padding:12px 16px 16px;box-sizing:border-box}",
338
390
  ".mneme-graphbar{display:flex;gap:8px;align-items:center;flex:none;margin-bottom:10px}",
@@ -354,6 +406,21 @@ window.__ModuleLoader__.load({
354
406
  ".mneme-gs-attrval{color:var(--dsw-alias-label-secondary);word-break:break-word;font-size:13px}",
355
407
  ".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
408
  ".mneme-gs-link:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
409
+ // --- directory sub-view (v0.6.3): tag folders + memory entries ---
410
+ ".mneme-directory{flex:1;min-height:0;overflow-y:auto;padding:8px 10px 28px}",
411
+ ".mneme-dir-folder{margin-bottom:4px}",
412
+ ".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}",
413
+ ".mneme-dir-folderhead:hover{background:var(--dsw-alias-interactive-bg-hover)}",
414
+ ".mneme-dir-caret{flex:none;width:10px;text-align:center;color:var(--dsw-alias-label-tertiary);font-size:12px}",
415
+ ".mneme-dir-fname{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
416
+ ".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}",
417
+ ".mneme-dir-fbody{margin:2px 0 4px 22px;padding-left:8px;border-left:1px solid var(--dsw-alias-border-l2)}",
418
+ ".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}",
419
+ ".mneme-dir-item:hover{background:var(--dsw-alias-interactive-bg-hover)}",
420
+ ".mneme-dir-item.mneme-active{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary)}",
421
+ ".mneme-dir-ititle{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
422
+ ".mneme-dir-itime{flex:none;font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}",
423
+ ".mneme-dir-empty{color:var(--dsw-alias-label-tertiary);padding:24px 0;text-align:center;font-size:13px}",
357
424
  // --- settings sub-view ---
358
425
  ".mneme-xsettings{flex:1;min-height:0;overflow-y:auto;padding:24px 24px 48px}",
359
426
  ".mneme-xsettings-inner{max-width:640px}",
@@ -409,6 +476,92 @@ window.__ModuleLoader__.load({
409
476
  });
410
477
  }
411
478
 
479
+ // --- wiki-link (v0.6.1) -------------------------------------------------
480
+ // Splits detail content into literal text and clickable [[target]] /
481
+ // [[display|target]] links. Mirrors src/parser/wiki-link.js: single pipe
482
+ // only, empty / multi-pipe / unclosed markers stay literal text.
483
+ function wikilinkSegments(text, onClick) {
484
+ if (typeof text !== "string" || text.length === 0) return text;
485
+ const out = [];
486
+ let last = 0;
487
+ let key = 0;
488
+ const re = /\[\[([^\[\]]*)\]\]/g;
489
+ let m;
490
+ while ((m = re.exec(text)) !== null) {
491
+ const parts = m[1].split("|");
492
+ if (parts.length > 2) continue; // 多管道 → 非法,保留字面文本
493
+ const target = (parts.length === 2 ? parts[1] : parts[0]).trim();
494
+ if (!target) continue; // 空目标 → 非法
495
+ if (m.index > last) out.push(text.slice(last, m.index));
496
+ out.push(h("button", {
497
+ key: `w${key++}`,
498
+ type: "button",
499
+ className: "mneme-wikilink",
500
+ title: target,
501
+ onClick: () => onClick(target)
502
+ }, parts[0].trim() || target));
503
+ last = m.index + m[0].length;
504
+ }
505
+ if (last < text.length) out.push(text.slice(last));
506
+ return out.length ? out : text;
507
+ }
508
+
509
+ // Backlinks panel: the two wiki-link relation blocks under the detail pane.
510
+ // Backlinks = memories whose content links to this one; forward = memories
511
+ // this one links to (unresolved targets surface as dim, non-clickable).
512
+ // Mounted per-selection (the surrounding Fragment keys on selected.id), so
513
+ // state resets and the fetches re-run on every switch.
514
+ function BacklinksPanel({ memory, t, onJump }) {
515
+ const [back, setBack] = useState(null); // null = loading
516
+ const [forward, setForward] = useState(null);
517
+
518
+ useEffect(() => {
519
+ let cancelled = false;
520
+ if (!memory || !memory.id) return;
521
+ apiFetch(`/api/dsh-mneme/wikilinks/backlinks?id=${encodeURIComponent(memory.id)}`)
522
+ .then((r) => (r.ok ? r.json() : { backlinks: [] }))
523
+ .then((j) => { if (!cancelled) setBack(Array.isArray(j.backlinks) ? j.backlinks : []); })
524
+ .catch(() => { if (!cancelled) setBack([]); });
525
+ apiFetch(`/api/dsh-mneme/wikilinks/forward?id=${encodeURIComponent(memory.id)}`)
526
+ .then((r) => (r.ok ? r.json() : { links: [] }))
527
+ .then((j) => { if (!cancelled) setForward(Array.isArray(j.links) ? j.links : []); })
528
+ .catch(() => { if (!cancelled) setForward([]); });
529
+ return () => { cancelled = true; };
530
+ }, [memory && memory.id]);
531
+
532
+ if (!memory || !memory.id) return null;
533
+
534
+ const loading = back === null || forward === null;
535
+ const row = (link, i) => {
536
+ const canJump = !!link.id;
537
+ return h("button", {
538
+ key: link.id ?? `unresolved-${i}`,
539
+ type: "button",
540
+ className: canJump ? "mneme-bl-link" : "mneme-bl-link mneme-bl-link--dim",
541
+ title: canJump ? t("memory.card.open") : t("memory.wikilink.unresolved"),
542
+ disabled: !canJump,
543
+ onClick: () => onJump && onJump({ id: link.id })
544
+ },
545
+ h("span", { className: "mneme-bl-title" }, link.title || "—"),
546
+ link.type && h("span", { className: "mneme-bl-meta" }, typeLabel(t, link.type))
547
+ );
548
+ };
549
+ const section = (label, items) =>
550
+ h("div", { className: "mneme-bl-block" },
551
+ h("div", { className: "mneme-bl-head" }, label),
552
+ loading
553
+ ? h("div", { className: "mneme-bl-empty" }, t("memory.wikilink.loading"))
554
+ : items.length === 0
555
+ ? h("div", { className: "mneme-bl-empty" }, t("memory.wikilink.empty"))
556
+ : h("div", { className: "mneme-bl-list" }, items.map(row))
557
+ );
558
+
559
+ return h("div", { className: "mneme-backlinks" },
560
+ section(t("memory.wikilink.backlinks"), back || []),
561
+ section(t("memory.wikilink.forward"), forward || [])
562
+ );
563
+ }
564
+
412
565
  // --- Graph view: ego-graph of one entity, zero-dependency SVG force layout ---
413
566
  // The DSH client module table only whitelists platform modules, so a graph
414
567
  // library like vis-network cannot be required from a plugin. A hand-rolled
@@ -957,10 +1110,86 @@ window.__ModuleLoader__.load({
957
1110
  return tree;
958
1111
  }
959
1112
 
1113
+ // --- directory sub-view (v0.6.3) ---
1114
+ // A tag folder tree over the live memory set. One-level folders keyed by
1115
+ // tag (handled on the server, getDirectory), each with a 二级 memory
1116
+ // entry list (title + updated time); memories without a live tag land in
1117
+ // the "untagged" folder. Collapse state lives in MemoryExplorer (lifted via
1118
+ // `collapsed`), so switching tabs does not drop it. Clicking an entry jumps
1119
+ // back into the browser with that memory selected (onJump = jumpToMemory).
1120
+ function DirectoryPanel({ t, onJump, collapsed, setCollapsed }) {
1121
+ const [dir, setDir] = useState(null); // { groups: [...], untagged: [...] }
1122
+ const [status, setStatus] = useState("loading"); // loading | ready | error
1123
+
1124
+ useEffect(() => {
1125
+ let cancelled = false;
1126
+ setStatus("loading");
1127
+ apiFetch("/api/dsh-mneme/directory")
1128
+ .then((res) => (res.ok ? res.json() : null))
1129
+ .then((d) => {
1130
+ if (cancelled) return;
1131
+ if (!d) { setDir(null); setStatus("error"); return; }
1132
+ setDir({ groups: Array.isArray(d.groups) ? d.groups : [], untagged: Array.isArray(d.untagged) ? d.untagged : [] });
1133
+ setStatus("ready");
1134
+ })
1135
+ .catch(() => { if (!cancelled) { setDir(null); setStatus("error"); } });
1136
+ return () => { cancelled = true; };
1137
+ }, []);
1138
+
1139
+ const toggle = (key) => setCollapsed((c) => ({ ...c, [key]: !c[key] }));
1140
+
1141
+ const renderFolder = (name, memories, extraKey, emptyLabel) => {
1142
+ const folderKey = extraKey || name;
1143
+ const open = !collapsed[folderKey];
1144
+ const rows = memories.map((m) =>
1145
+ h("button", {
1146
+ key: m.id,
1147
+ type: "button",
1148
+ className: "mneme-dir-item",
1149
+ onClick: () => onJump(m)
1150
+ },
1151
+ h("span", { className: "mneme-dir-ititle" }, m.title || m.content?.slice(0, 40)),
1152
+ h("span", { className: "mneme-dir-itime" }, formatDate(m.updated_at || m.created_at))
1153
+ )
1154
+ );
1155
+ return h("div", {
1156
+ key: folderKey,
1157
+ className: "mneme-dir-folder",
1158
+ "data-expanded": String(open)
1159
+ },
1160
+ h("button", {
1161
+ type: "button",
1162
+ className: "mneme-dir-folderhead",
1163
+ "aria-expanded": String(open),
1164
+ onClick: () => toggle(folderKey)
1165
+ },
1166
+ h("span", { className: "mneme-dir-caret" }, open ? "▾" : "▸"),
1167
+ h("span", { className: "mneme-dir-fname" }, name),
1168
+ h("span", { className: "mneme-dir-fcount" }, String(memories.length))
1169
+ ),
1170
+ open && h("div", { className: "mneme-dir-fbody" },
1171
+ rows.length > 0 ? rows : h("div", { className: "mneme-dir-empty" }, emptyLabel || t("memory.directory.empty"))
1172
+ )
1173
+ );
1174
+ };
1175
+
1176
+ if (status === "loading") {
1177
+ return h("div", { className: "mneme-directory" }, h("div", { className: "mneme-dir-empty" }, t("memory.directory.loading")));
1178
+ }
1179
+ const hasAny = dir && (dir.groups.length > 0 || dir.untagged.length > 0);
1180
+ if (status === "error" || !hasAny) {
1181
+ return h("div", { className: "mneme-directory" }, h("div", { className: "mneme-dir-empty" }, t("memory.directory.empty")));
1182
+ }
1183
+ return h("div", { className: "mneme-directory" },
1184
+ dir.groups.map((g) => renderFolder(g.tag, g.memories)),
1185
+ dir.untagged.length > 0 && renderFolder(t("memory.directory.untagged"), dir.untagged, "mneme-untagged")
1186
+ );
1187
+ }
1188
+
960
1189
  const EXPLORER_TYPES = ["preference", "project", "decision", "summary", "history"];
961
1190
 
962
1191
  function MemoryExplorer({ t }) {
963
- const [view, setView] = useState("memory"); // memory | graph | settings
1192
+ const [view, setView] = useState("memory"); // memory | directory | graph | settings
964
1193
  const [items, setItems] = useState([]);
965
1194
  const [loading, setLoading] = useState(true);
966
1195
  const [type, setType] = useState("all");
@@ -971,9 +1200,20 @@ window.__ModuleLoader__.load({
971
1200
  const [remoteItems, setRemoteItems] = useState(null);
972
1201
  const [selectedId, setSelectedId] = useState(null);
973
1202
  const [collapsed, setCollapsed] = useState({});
1203
+ // v0.6.3 directory sub-view: its own folder-collapse state, kept in
1204
+ // MemoryExplorer so switching tabs does not lose it and jumpToMemory's
1205
+ // time-tree reset (setCollapsed({})) cannot clobber it.
1206
+ const [dirCollapsed, setDirCollapsed] = useState({});
974
1207
  const [copied, setCopied] = useState(false);
975
1208
  const [reloadKey, setReloadKey] = useState(0);
976
1209
  const [graphFocus, setGraphFocus] = useState("");
1210
+ // v0.6.2 tag editing state for the detail pane. The authoritative tag set
1211
+ // lives server-side (entity_attrs), so it is fetched per selected memory
1212
+ // and refreshed after every write instead of trusting the list snapshot.
1213
+ const [tagTags, setTagTags] = useState([]);
1214
+ const [tagManual, setTagManual] = useState(true);
1215
+ const [tagInput, setTagInput] = useState("");
1216
+ const [tagAdding, setTagAdding] = useState(false);
977
1217
  const itemRefs = useRef(new Map());
978
1218
 
979
1219
  useEffect(() => {
@@ -994,10 +1234,12 @@ window.__ModuleLoader__.load({
994
1234
  }, [reloadKey]);
995
1235
 
996
1236
  // Semantic search: server-side ranking replaces the client filter
997
- // while enabled and a query is present (debounced).
1237
+ // while enabled and a query is present (debounced). tag: queries always
1238
+ // go server-side (searchMemories resolves them without vector support),
1239
+ // so they bypass the semantic toggle and use the same search endpoint.
998
1240
  useEffect(() => {
999
1241
  const q = query.trim();
1000
- if (!semantic || !q || q.startsWith("entity:")) { setRemoteItems(null); return; }
1242
+ if ((!semantic && !q.startsWith("tag:")) || !q || q.startsWith("entity:")) { setRemoteItems(null); return; }
1001
1243
  let cancelled = false;
1002
1244
  const timer = setTimeout(() => {
1003
1245
  apiFetch(`/api/dsh-mneme/search?q=${encodeURIComponent(q)}&mode=vector&topK=${searchTopK}`)
@@ -1013,6 +1255,30 @@ window.__ModuleLoader__.load({
1013
1255
  itemRefs.current.get(selectedId)?.scrollIntoView({ block: "nearest" });
1014
1256
  }, [selectedId]);
1015
1257
 
1258
+ // Load the authoritative tag set for the selected memory (entity_attrs-
1259
+ // backed) plus the manualTagEnabled gate that hides editing when off.
1260
+ const loadTags = (id) => {
1261
+ if (!id) { setTagTags([]); setTagManual(true); setTagInput(""); return; }
1262
+ apiFetch(`/api/dsh-mneme/memory/tags?id=${encodeURIComponent(id)}`)
1263
+ .then((res) => (res.ok ? res.json() : null))
1264
+ .then((j) => { if (j) { setTagTags(Array.isArray(j.tags) ? j.tags : []); setTagManual(j.manualTagEnabled !== false); } })
1265
+ .catch(() => {});
1266
+ };
1267
+ useEffect(() => {
1268
+ let cancelled = false;
1269
+ if (selectedId) {
1270
+ apiFetch(`/api/dsh-mneme/memory/tags?id=${encodeURIComponent(selectedId)}`)
1271
+ .then((res) => (res.ok ? res.json() : null))
1272
+ .then((j) => { if (!cancelled && j) { setTagTags(Array.isArray(j.tags) ? j.tags : []); setTagManual(j.manualTagEnabled !== false); } })
1273
+ .catch(() => {});
1274
+ } else {
1275
+ setTagTags([]);
1276
+ setTagManual(true);
1277
+ }
1278
+ setTagAdding(false);
1279
+ return () => { cancelled = true; };
1280
+ }, [selectedId]);
1281
+
1016
1282
  const q = query.trim().toLowerCase();
1017
1283
  // "entity:" is the graph entry grammar: typing it means the user wants
1018
1284
  // the entity's neighborhood, not a memory list. Offer the jump instead
@@ -1072,6 +1338,42 @@ window.__ModuleLoader__.load({
1072
1338
  );
1073
1339
  };
1074
1340
 
1341
+ // v0.6.2 tag editing: a chip click jumps to a tag:xxx search (reusing
1342
+ // the query-driven search flow), the + entry and per-chip × post the new
1343
+ // tag set through the tags endpoint then re-fetch. A 409 from the server
1344
+ // means manualTagEnabled turned off — flip the gate so editing hides.
1345
+ const submitTag = () => {
1346
+ const tag = tagInput.trim();
1347
+ if (!tag || !selectedId) return;
1348
+ apiFetch("/api/dsh-mneme/memory/tags", {
1349
+ method: "POST",
1350
+ headers: { "Content-Type": "application/json" },
1351
+ body: JSON.stringify({ id: selectedId, tags: [...tagTags, tag] })
1352
+ }).then((res) => {
1353
+ if (res.status === 409) { setTagManual(false); setTagAdding(false); setTagInput(""); return; }
1354
+ return res.json();
1355
+ }).then((j) => {
1356
+ if (j?.ok) { setTagAdding(false); setTagInput(""); loadTags(selectedId); }
1357
+ }).catch(() => {});
1358
+ };
1359
+ const removeTag = (tag) => {
1360
+ if (!selectedId) return;
1361
+ apiFetch("/api/dsh-mneme/memory/tags", {
1362
+ method: "POST",
1363
+ headers: { "Content-Type": "application/json" },
1364
+ body: JSON.stringify({ id: selectedId, tags: tagTags.filter((x) => x !== tag) })
1365
+ }).then((res) => {
1366
+ if (res.status === 409) { setTagManual(false); return; }
1367
+ return res.json();
1368
+ }).then((j) => {
1369
+ if (j?.ok) loadTags(selectedId);
1370
+ }).catch(() => {});
1371
+ };
1372
+ const onTagClick = (tag) => {
1373
+ setType("all");
1374
+ setQuery(`tag:${tag}`);
1375
+ };
1376
+
1075
1377
  // Graph → memory jump: land on the browser tab with filters reset so
1076
1378
  // the target row is visible, selected and scrolled into view.
1077
1379
  const jumpToMemory = (target) => {
@@ -1083,6 +1385,17 @@ window.__ModuleLoader__.load({
1083
1385
  setSelectedId(target.id);
1084
1386
  };
1085
1387
 
1388
+ // [[wiki-link]] click in the detail content: resolve the title to a
1389
+ // memory (case-insensitive exact match) then jump to it. Unresolvable
1390
+ // titles silently no-op — the link stays as display text.
1391
+ const onWikilinkClick = (target) => {
1392
+ if (!target) return;
1393
+ apiFetch(`/api/dsh-mneme/wikilinks/resolve?title=${encodeURIComponent(target)}`)
1394
+ .then((r) => (r.ok ? r.json() : null))
1395
+ .then((j) => { if (j?.memory?.id) jumpToMemory(j.memory); })
1396
+ .catch(() => {});
1397
+ };
1398
+
1086
1399
  const openGraphFor = (name) => {
1087
1400
  setGraphFocus(name || "");
1088
1401
  setView("graph");
@@ -1090,6 +1403,7 @@ window.__ModuleLoader__.load({
1090
1403
 
1091
1404
  const subviews = [
1092
1405
  { key: "memory", label: t("memory.explorer.tabMemory") },
1406
+ { key: "directory", label: t("memory.explorer.tabDirectory") },
1093
1407
  { key: "graph", label: t("memory.explorer.tabGraph") },
1094
1408
  { key: "settings", label: t("memory.explorer.tabSettings") }
1095
1409
  ];
@@ -1171,14 +1485,53 @@ window.__ModuleLoader__.load({
1171
1485
  h("span", { title: formatDate(selected.created_at) }, `${t("memory.explorer.created")}: ${formatDate(selected.created_at)}`),
1172
1486
  h("span", { title: formatDate(selected.updated_at) }, `${t("memory.explorer.updated")}: ${formatDate(selected.updated_at)}`)
1173
1487
  ),
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(" · ")}`)
1488
+ h("div", { className: "mneme-xdmeta" },
1489
+ h("span", null, t("memory.explorer.tags")),
1490
+ h("div", { className: "mneme-tagrow" },
1491
+ tagTags.length > 0
1492
+ ? tagTags.map((tag) =>
1493
+ h("span", {
1494
+ key: tag,
1495
+ className: "mneme-tagchip",
1496
+ title: `tag:${tag}`,
1497
+ onClick: () => onTagClick(tag)
1498
+ },
1499
+ tag,
1500
+ tagManual && h("button", {
1501
+ type: "button",
1502
+ className: "mneme-tagremove",
1503
+ "aria-label": `${t("memory.explorer.tagRemove")}: ${tag}`,
1504
+ onClick: (e) => { e.stopPropagation(); removeTag(tag); }
1505
+ }, "×")
1506
+ )
1507
+ )
1508
+ : h("span", { className: "mneme-tagempty" }, t("memory.explorer.tagsEmpty")),
1509
+ tagManual && (tagAdding
1510
+ ? h("input", {
1511
+ key: selectedId,
1512
+ className: "mneme-taginput",
1513
+ autoFocus: true,
1514
+ value: tagInput,
1515
+ placeholder: t("memory.explorer.tagPlaceholder"),
1516
+ onChange: (e) => setTagInput(e.target.value),
1517
+ onKeyDown: (e) => { if (e.key === "Enter") submitTag(); },
1518
+ onBlur: () => { setTagAdding(false); setTagInput(""); }
1519
+ })
1520
+ : h("button", {
1521
+ type: "button",
1522
+ className: "mneme-tagadd",
1523
+ onClick: () => setTagAdding(true)
1524
+ }, `+ ${t("memory.explorer.tagAdd")}`))
1525
+ )
1526
+ ),
1527
+ h("div", { className: "mneme-xdcontent" },
1528
+ selected.content ? wikilinkSegments(selected.content, onWikilinkClick) : selected.content
1176
1529
  ),
1177
- h("div", { className: "mneme-xdcontent" }, selected.content),
1178
1530
  h("div", { className: "mneme-xdactions" },
1179
1531
  h("button", { className: "mneme-footbtn", onClick: copyContent },
1180
1532
  copied ? t("memory.explorer.copied") : t("memory.explorer.copy"))
1181
- )
1533
+ ),
1534
+ h(BacklinksPanel, { memory: selected, t, onJump: jumpToMemory })
1182
1535
  )
1183
1536
  )
1184
1537
  : h("div", { className: "mneme-xempty" }, t("memory.explorer.emptyDetail"))
@@ -1229,6 +1582,7 @@ window.__ModuleLoader__.load({
1229
1582
  )
1230
1583
  )
1231
1584
  ),
1585
+ view === "directory" && h(DirectoryPanel, { t, onJump: jumpToMemory, collapsed: dirCollapsed, setCollapsed: setDirCollapsed }),
1232
1586
  view === "graph" && h(GraphPanel, { t, focusEntity: graphFocus, onJumpMemory: jumpToMemory }),
1233
1587
  view === "settings" && h("div", { className: "mneme-xsettings" },
1234
1588
  h("div", { className: "mneme-xsettings-inner" }, h(SettingsContent, { t }))
package/lib/config.js CHANGED
@@ -166,6 +166,33 @@ export const Config = z.object({
166
166
  // Prefix/semantic search over entity names (used by recall).
167
167
  entitySearchEnabled: z.boolean().default(true),
168
168
 
169
+ // --- wiki-link: explicit cross-memory [[links]] (v0.6.1) ----------------
170
+ // Opt-in, off by default. When enabled, saveWithDedupe/update fire-and-forget
171
+ // a wiki-link resolution pass: [[target]] / [[显示|target]] markers in a
172
+ // memory's content become links_to relations in entity_relations (idempotent,
173
+ // deduped by the unique relation index). The storage layer + read APIs
174
+ // (getBacklinks/getForwardLinks/resolveWikiLink) are always available
175
+ // regardless of this flag.
176
+ wikiLinkEnabled: z.boolean().default(false),
177
+
178
+ // --- tag system (v0.6.2) ---------------------------------------------------
179
+ // Opt-in: when autoTagEnabled is true, a light LLM pass runs after each
180
+ // autoDream consolidation and extracts 1-3 tags per retained memory
181
+ // (autoTagMaxPerRun bounds how many memories are tagged per run). The tag
182
+ // storage layer (store.setMemoryTags/getMemoryTags + tag: search + mirror
183
+ // `#tag` line) is always available regardless of this flag.
184
+ autoTagEnabled: z.boolean().default(false),
185
+ autoTagMaxPerRun: z.natural().min(1).max(100).default(10),
186
+ // Manual tagging (service.setMemoryTags / memory tools) is on by default;
187
+ // set false to disable the manual write path too.
188
+ manualTagEnabled: z.boolean().default(true),
189
+
190
+ // --- tag-weighted re-rank (v0.6.4) -------------------------------------
191
+ // Opt-in: boost candidates whose tags overlap the query/session tags.
192
+ tagBoostEnabled: z.boolean().default(false),
193
+ tagBoostFactor: z.number().min(1).max(2).default(1.15),
194
+ sessionTagBoostFactor: z.number().min(1).max(2).default(1.08),
195
+
169
196
  // --- sleep mode: idle-triggered deep maintenance (v0.4.0) ---------------
170
197
  // Opt-in, off by default. Unlike autoDream (threshold-triggered, lightweight)
171
198
  // sleep fires when the store has been quiet for sleepIdleMinutes and deep-