@modusensus/dsh-mneme 0.6.1 → 0.6.6

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/README.md CHANGED
@@ -116,7 +116,17 @@ dsh web
116
116
 
117
117
  **默认关闭**(`wikiLinkEnabled: false`)。开启后,记忆正文支持 `[[target]]` / `[[显示|target]]` 双括号链接语法:保存时自动解析并记录跨记忆 `links_to` 关系(partial 唯一索引只对 links_to 去重),记忆详情旁出现反向链接面板,点击可跳转来源记忆。提供只读 API:backlinks / forward-links / wikilink-resolve(输出脱敏)。
118
118
 
119
+ ### Tag 系统 🏷️(v0.6.2,opt-in)
119
120
 
121
+ `autoTagEnabled` 默认关。开启后:正文 `#标签` 即时标记(规则 `[a-zA-Z0-9_一-龥-]+`,≤20 字符,非法/超长自动丢弃);autoDream 整理后 LLM 自动打 1-3 个标签(`autoTagMaxPerRun=10` 频控,fail-safe);tags 存 `entity_attrs`;`tag:` 搜索前缀可与关键词/`entity:`/`attr:` 组合;mirror 顶部渲染 `#tag` 行;记忆面板标签可点击(过滤)、添加、移除(`manualTagEnabled` 默认开)。存储与搜索能力始终可用,`autoTagEnabled` 只控制自动打标。
122
+
123
+ ### 目录视图 📁(v0.6.3)
124
+
125
+ 记忆面板新增「目录」视图:以 Tag 为轴心的一级手风琴文件夹,无标签记忆自动沉底到「无标签」兜底组;组内按重要性/时间双降序;点击条目直达详情页。数据由 `GET /api/dsh-mneme/directory` 提供(只读、排除 disposed/archived/forgotten)。
126
+
127
+ ### Tag 加权召回 ⚖️(v0.6.4,opt-in)
128
+
129
+ `tagBoostEnabled` 默认关。开启后,三路召回合并出候选后做 tag 加权重排:候选记忆 tags 与 Query 提取 tags(`#xxx` + 已知 tag 列表)交集 → 分数 `×1.15`;与当前 Session 热记忆 tags 交集 → `×1.08`(可叠加,上限 1.0)。关闭时行为与旧版完全一致,可用 `evaluateRetrieval` 开关对比调优。
120
130
 
121
131
  官方设置面板 → 「记忆库设置」→「记忆」标签:按类型浏览、全文搜索;启用向量搜索后可用「语义」切换做向量召回。
122
132
 
@@ -216,6 +226,10 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
216
226
 
217
227
  | 版本 | 亮点 |
218
228
  |------|------|
229
+ | **v0.6.5** | 整合 v0.6.2-0.6.4:Tag 系统 + 目录视图 + Tag 加权召回(全部 opt-in);709 测试全绿 |
230
+ | **v0.6.4** | Tag 加权召回:query/session tag 交集 boost(`tagBoostEnabled` 默认关) |
231
+ | **v0.6.3** | 目录视图:Tag 文件夹 + 无标签兜底 + 点击跳详情 |
232
+ | **v0.6.2** | Tag 系统:`#标签` + autoDream 自动打标 + `tag:` 搜索 + 面板 chips |
219
233
  | **v0.6.1** | Wiki-Link 双向链接(笔记化记忆库第一步):[[笔记]] 跨记忆链接 + 反向链接面板 + links_to partial 唯一索引;654 测试全绿 |
220
234
  | **v0.6.0** | 会话生命周期(把对话当存档点):`session_disposed_at` 独立字段软隐藏会话删除的记忆(与 `archived` 正交,可恢复)+ `memory_delete` 支持描述删除 + 事件订阅熔断;阿里云 kimi-k2.7-code 审查 4 项修复;628 测试全绿 |
221
235
  | **v0.5.0** | 主区「记忆库」视图(取代侧边栏抽屉)+ 记忆图谱可视化(ego-graph API + 零依赖 SVG 力导向)+ BM25 三路召回融合 + 自适应阈值 + 会话热记忆 + 召回基准评测;593 测试全绿 |
@@ -245,6 +259,9 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
245
259
  | **v0.5.0** | ✅ 完成 | 召回融合与记忆可视化 | 主区「记忆库」视图 + 记忆图谱(ego-graph API + 零依赖 SVG 力导向)+ BM25 三路召回融合 + 自适应阈值 + 会话热记忆 + 召回基准;593 测试全绿 |
246
260
  | **v0.6.0** | ✅ 完成 | 会话生命周期 | 把对话当存档点:`session_disposed_at` 软隐藏会话删除的记忆(与 `archived` 正交、可恢复)+ `memory_delete` 描述删除 + 事件熔断;628 测试全绿 |
247
261
  | **v0.6.1** | ✅ 完成 | Wiki-Link 双向链接 | 笔记化记忆库第一步:`[[target]]` 跨记忆链接 + 反向链接面板 + links_to partial 唯一索引;654 测试全绿 |
262
+ | **v0.6.2** | ✅ 完成 | Tag 系统 | `#标签` 解析 + autoDream 自动打标 + `tag:` 搜索 + 面板 chips + mirror `#tag` 行(全部 opt-in) |
263
+ | **v0.6.3** | ✅ 完成 | 目录视图 | Tag 文件夹手风琴 + 无标签兜底 + 点击跳详情 + `GET /api/dsh-mneme/directory` 端点 |
264
+ | **v0.6.4** | ✅ 完成 | Tag 加权召回 | query/session tag 交集 boost(×1.15 / ×1.08),`tagBoostEnabled` 默认关 |
248
265
  | **v0.7.0+** | 🚀 远期 | 自进化记忆 | 兴趣漂移跟踪 + 跨 workspace 记忆共享(等 DSH 支持) |
249
266
 
250
267
  > 新能力一律做成**可开关的功能**(配置启用/关闭),默认保守开启、不破坏现有行为。`failure_memories` 表与 autoDream 决策引擎已为后续反思性成长铺好路。
package/lib/api.js CHANGED
@@ -608,8 +608,84 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
608
608
  }
609
609
  });
610
610
 
611
+ // --- memory tags (v0.6.2) ------------------------------------------------
612
+ // GET /api/dsh-mneme/memory/tags?id=<memoryId> → live entity_attrs-backed
613
+ // tag set for one memory plus the manualTagEnabled gate (so the panel
614
+ // can hide tag editing when the manual path is off). Read-only, stays
615
+ // open when apiToken is set (like list/search/semantic).
616
+ // POST /api/dsh-mneme/memory/tags { id, tags } → overwrite the live tag set
617
+ // via service.setMemoryTags (manualTagEnabled gate); 409 when the gate
618
+ // is closed. Auth-gated like the other write endpoints.
619
+ register({
620
+ kind: "exact",
621
+ path: "/api/dsh-mneme/memory/tags",
622
+ handler(req, res) {
623
+ try {
624
+ if (req.method === "POST" || req.method === "PUT") {
625
+ if (!requireAuth(req, res, apiToken)) return;
626
+ return readBody(req).then((text) => {
627
+ const body = parseBody(text);
628
+ const id = typeof body.id === "string" ? body.id.trim() : "";
629
+ if (!id) {
630
+ sendJson(res, 400, { error: "missing-id" });
631
+ return;
632
+ }
633
+ const memory = service.getById?.(id) ?? null;
634
+ if (!memory) {
635
+ sendJson(res, 404, { error: "memory-not-found" });
636
+ return;
637
+ }
638
+ const result = service.setMemoryTags(id, Array.isArray(body.tags) ? body.tags : []);
639
+ if (result?.ok === false) {
640
+ sendJson(res, 409, { error: result.error || "tags-disabled" });
641
+ return;
642
+ }
643
+ sendJson(res, 200, { ok: true, memoryId: id, tags: result?.tags ?? [] });
644
+ });
645
+ }
646
+ const url = new URL(req.url, "http://localhost");
647
+ const id = (url.searchParams.get("id") ?? "").trim();
648
+ if (!id) {
649
+ sendJson(res, 400, { error: "missing-id" });
650
+ return;
651
+ }
652
+ const memory = service.getById?.(id) ?? null;
653
+ if (!memory) {
654
+ sendJson(res, 404, { error: "memory-not-found" });
655
+ return;
656
+ }
657
+ const tags = service.getMemoryTags?.(id) ?? [];
658
+ sendJson(res, 200, {
659
+ memoryId: id,
660
+ tags: Array.isArray(tags) ? tags : [],
661
+ manualTagEnabled: service.manualTagEnabled?.() ?? true
662
+ });
663
+ } catch {
664
+ sendJson(res, 500, { error: "internal" });
665
+ }
666
+ }
667
+ });
668
+
669
+ // --- directory view (v0.6.3) ---------------------------------------------
670
+ // GET /api/dsh-mneme/directory → memories grouped by tag as
671
+ // { groups: [{ tag, memories: [...] }], untagged: [...] }. Live-only
672
+ // (forgotten/archived/session-disposed excluded), groups tag-sorted, members
673
+ // importance+updated DESC. Read-only, stays open when apiToken is set.
674
+ register({
675
+ kind: "exact",
676
+ path: "/api/dsh-mneme/directory",
677
+ handler(req, res) {
678
+ try {
679
+ const dir = service.getDirectory?.() ?? { groups: [], untagged: [] };
680
+ sendJson(res, 200, dir);
681
+ } catch {
682
+ sendJson(res, 500, { error: "internal" });
683
+ }
684
+ }
685
+ });
686
+
611
687
  return {
612
- routes: 11,
688
+ routes: 19,
613
689
  dispose: () => {
614
690
  for (const dispose of disposers) dispose();
615
691
  }
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}秒前",
@@ -180,6 +188,7 @@ window.__ModuleLoader__.load({
180
188
  "memory.overlay.close": "Close",
181
189
  "memory.explorer.tabMemory": "Memories",
182
190
  "memory.explorer.tabGraph": "Graph",
191
+ "memory.explorer.tabDirectory": "Directory",
183
192
  "memory.explorer.tabSettings": "Settings",
184
193
  "memory.explorer.search": "Search title or content…",
185
194
  "memory.explorer.searchTitle": "Semantic Search",
@@ -196,9 +205,16 @@ window.__ModuleLoader__.load({
196
205
  "memory.explorer.created": "Created",
197
206
  "memory.explorer.updated": "Updated",
198
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",
199
212
  "memory.explorer.importance": "Importance",
200
213
  "memory.explorer.topK": "Results limit",
201
214
  "memory.explorer.topKOption": "Return {n}",
215
+ "memory.directory.untagged": "Untagged",
216
+ "memory.directory.loading": "Loading…",
217
+ "memory.directory.empty": "No memories yet",
202
218
  "memory.card.open": "Open full text in the Memory tab",
203
219
  "memory.time.now": "just now",
204
220
  "memory.time.seconds": "{n}s ago",
@@ -299,6 +315,17 @@ window.__ModuleLoader__.load({
299
315
  ".mneme-hint{color:var(--dsw-alias-label-tertiary);padding:24px 0;text-align:center;font-size:13px}",
300
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}",
301
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}",
302
329
  // --- main-area memory library page ---
303
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)}",
304
331
  ".mneme-xbar{flex:none;display:flex;align-items:center;gap:6px;border-bottom:1px solid var(--dsw-alias-border-l2);padding:0 16px}",
@@ -379,6 +406,21 @@ window.__ModuleLoader__.load({
379
406
  ".mneme-gs-attrval{color:var(--dsw-alias-label-secondary);word-break:break-word;font-size:13px}",
380
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}",
381
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}",
382
424
  // --- settings sub-view ---
383
425
  ".mneme-xsettings{flex:1;min-height:0;overflow-y:auto;padding:24px 24px 48px}",
384
426
  ".mneme-xsettings-inner{max-width:640px}",
@@ -1068,10 +1110,86 @@ window.__ModuleLoader__.load({
1068
1110
  return tree;
1069
1111
  }
1070
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
+
1071
1189
  const EXPLORER_TYPES = ["preference", "project", "decision", "summary", "history"];
1072
1190
 
1073
1191
  function MemoryExplorer({ t }) {
1074
- const [view, setView] = useState("memory"); // memory | graph | settings
1192
+ const [view, setView] = useState("memory"); // memory | directory | graph | settings
1075
1193
  const [items, setItems] = useState([]);
1076
1194
  const [loading, setLoading] = useState(true);
1077
1195
  const [type, setType] = useState("all");
@@ -1082,9 +1200,20 @@ window.__ModuleLoader__.load({
1082
1200
  const [remoteItems, setRemoteItems] = useState(null);
1083
1201
  const [selectedId, setSelectedId] = useState(null);
1084
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({});
1085
1207
  const [copied, setCopied] = useState(false);
1086
1208
  const [reloadKey, setReloadKey] = useState(0);
1087
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);
1088
1217
  const itemRefs = useRef(new Map());
1089
1218
 
1090
1219
  useEffect(() => {
@@ -1105,10 +1234,12 @@ window.__ModuleLoader__.load({
1105
1234
  }, [reloadKey]);
1106
1235
 
1107
1236
  // Semantic search: server-side ranking replaces the client filter
1108
- // 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.
1109
1240
  useEffect(() => {
1110
1241
  const q = query.trim();
1111
- if (!semantic || !q || q.startsWith("entity:")) { setRemoteItems(null); return; }
1242
+ if ((!semantic && !q.startsWith("tag:")) || !q || q.startsWith("entity:")) { setRemoteItems(null); return; }
1112
1243
  let cancelled = false;
1113
1244
  const timer = setTimeout(() => {
1114
1245
  apiFetch(`/api/dsh-mneme/search?q=${encodeURIComponent(q)}&mode=vector&topK=${searchTopK}`)
@@ -1124,6 +1255,30 @@ window.__ModuleLoader__.load({
1124
1255
  itemRefs.current.get(selectedId)?.scrollIntoView({ block: "nearest" });
1125
1256
  }, [selectedId]);
1126
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
+
1127
1282
  const q = query.trim().toLowerCase();
1128
1283
  // "entity:" is the graph entry grammar: typing it means the user wants
1129
1284
  // the entity's neighborhood, not a memory list. Offer the jump instead
@@ -1183,6 +1338,42 @@ window.__ModuleLoader__.load({
1183
1338
  );
1184
1339
  };
1185
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
+
1186
1377
  // Graph → memory jump: land on the browser tab with filters reset so
1187
1378
  // the target row is visible, selected and scrolled into view.
1188
1379
  const jumpToMemory = (target) => {
@@ -1212,6 +1403,7 @@ window.__ModuleLoader__.load({
1212
1403
 
1213
1404
  const subviews = [
1214
1405
  { key: "memory", label: t("memory.explorer.tabMemory") },
1406
+ { key: "directory", label: t("memory.explorer.tabDirectory") },
1215
1407
  { key: "graph", label: t("memory.explorer.tabGraph") },
1216
1408
  { key: "settings", label: t("memory.explorer.tabSettings") }
1217
1409
  ];
@@ -1293,8 +1485,44 @@ window.__ModuleLoader__.load({
1293
1485
  h("span", { title: formatDate(selected.created_at) }, `${t("memory.explorer.created")}: ${formatDate(selected.created_at)}`),
1294
1486
  h("span", { title: formatDate(selected.updated_at) }, `${t("memory.explorer.updated")}: ${formatDate(selected.updated_at)}`)
1295
1487
  ),
1296
- Array.isArray(selected.tags) && selected.tags.length > 0 && h("div", { className: "mneme-xdmeta" },
1297
- 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
+ )
1298
1526
  ),
1299
1527
  h("div", { className: "mneme-xdcontent" },
1300
1528
  selected.content ? wikilinkSegments(selected.content, onWikilinkClick) : selected.content
@@ -1354,6 +1582,7 @@ window.__ModuleLoader__.load({
1354
1582
  )
1355
1583
  )
1356
1584
  ),
1585
+ view === "directory" && h(DirectoryPanel, { t, onJump: jumpToMemory, collapsed: dirCollapsed, setCollapsed: setDirCollapsed }),
1357
1586
  view === "graph" && h(GraphPanel, { t, focusEntity: graphFocus, onJumpMemory: jumpToMemory }),
1358
1587
  view === "settings" && h("div", { className: "mneme-xsettings" },
1359
1588
  h("div", { className: "mneme-xsettings-inner" }, h(SettingsContent, { t }))
package/lib/config.js CHANGED
@@ -175,6 +175,24 @@ export const Config = z.object({
175
175
  // regardless of this flag.
176
176
  wikiLinkEnabled: z.boolean().default(false),
177
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
+
178
196
  // --- sleep mode: idle-triggered deep maintenance (v0.4.0) ---------------
179
197
  // Opt-in, off by default. Unlike autoDream (threshold-triggered, lightweight)
180
198
  // sleep fires when the store has been quiet for sleepIdleMinutes and deep-
@@ -0,0 +1,156 @@
1
+ // v0.6.2 auto-tag: a lightweight LLM pass that runs after an autoDream
2
+ // consolidation and extracts 1-3 tags per retained memory. Opt-in via
3
+ // config.autoTagEnabled (default false); autoTagMaxPerRun (default 10) bounds
4
+ // how many memories are tagged per run.
5
+ //
6
+ // Design notes:
7
+ // - ONE batched LLM call per run (the consolidation route is reused). Each
8
+ // memory is a prompt line; the model replies with a JSON array of
9
+ // {"id","tags"} entries — the per-memory contract is `{"tags":[...]}`.
10
+ // - Fail-safe everywhere: a missing route / aborted stream / unparseable
11
+ // JSON / unknown id / illegal tag are all skipped, never thrown. Tagging
12
+ // must never degrade the consolidation run it rides on.
13
+ // - The actual writes go through service.applyMemoryTags inside a
14
+ // service.transaction, so the mirror re-renders exactly once per pass and
15
+ // the write hooks fire once (never per memory).
16
+ import { sanitizeTags, MAX_TAG_LENGTH } from "../parser/tag.js";
17
+
18
+ const TAG_PROMPT = `你是记忆库标签助手。下面是保留的记忆条目(id、标题、内容)。
19
+ 对每条记忆提取 1-3 个中文或英文标签,用于检索分类。
20
+ 标签规则:
21
+ - 只允许字符:字母、数字、下划线、中文、连字符(如:linux、考研、deepseek-r1)
22
+ - 标签长度 ≤ ${MAX_TAG_LENGTH} 字符
23
+ - 宁缺毋滥:提取最核心的 1-3 个,不要凑数
24
+ - 不要输出内容里没有依据的标签
25
+ 只输出一个 JSON 数组,每项形如 { "id": "<记忆id>", "tags": ["标签1", "标签2"] }。
26
+ 不要输出其他文字。`;
27
+
28
+ /** Same stream consumption contract as dream.js. Returns accumulated text or
29
+ * undefined when the stream aborted/errored. */
30
+ async function streamText(ctx, options) {
31
+ if (!ctx?.llm?.stream) return undefined;
32
+ let text = "";
33
+ for await (const chunk of ctx.llm.stream(options)) {
34
+ if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
35
+ if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
36
+ return undefined;
37
+ }
38
+ }
39
+ return text;
40
+ }
41
+
42
+ /** Pull the outermost JSON array out of a model reply (same tolerant contract
43
+ * as sleep.js parseJsonArray): find the first `[` … last `]` and parse. */
44
+ function parseJsonArray(text) {
45
+ if (typeof text !== "string") return undefined;
46
+ const start = text.indexOf("[");
47
+ const end = text.lastIndexOf("]");
48
+ if (start === -1 || end <= start) return undefined;
49
+ try {
50
+ const parsed = JSON.parse(text.slice(start, end + 1));
51
+ return Array.isArray(parsed) ? parsed : undefined;
52
+ } catch {
53
+ return undefined;
54
+ }
55
+ }
56
+
57
+ /** Resolve the LLM route for the tag pass: the caller-provided consolidation
58
+ * route first, then dreamProvider/dreamModel. Falls through to undefined. */
59
+ function resolveTagRoute(route, config, logger) {
60
+ if (route?.provider && route?.model) return route;
61
+ if (config?.dreamProvider && config?.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
62
+ logger?.warn?.("dsh-mneme auto-tag: no llm route available");
63
+ return undefined;
64
+ }
65
+
66
+ /**
67
+ * Run the auto-tag pass over retained memories.
68
+ * @param {object} opts
69
+ * @param {object} opts.ctx — { llm, logger }
70
+ * @param {object} opts.service — service handle (all/transaction/applyMemoryTags)
71
+ * @param {object} opts.config — plugin config (autoTagMaxPerRun, dreamProvider…)
72
+ * @param {object} [opts.route] — already-resolved consolidation route
73
+ * @returns {Promise<{ok: boolean, tagged: number, skipped: number, failed: number, skippedBy: boolean}>}
74
+ */
75
+ export async function runAutoTag({ ctx, service, config, route }) {
76
+ const logger = ctx?.logger;
77
+ const maxPerRun = Number.isInteger(config?.autoTagMaxPerRun) && config.autoTagMaxPerRun > 0
78
+ ? config.autoTagMaxPerRun
79
+ : 10;
80
+ // Retained = post-consolidation active memories, newest first, capped.
81
+ // Skip memories that already carry live tags — otherwise every run re-picks
82
+ // the same "newest" batch (tagging doesn't bump updated_at) and older
83
+ // memories starve; manual tags are also left untouched this way.
84
+ const memories = service.all()
85
+ .filter((m) => !m.forgotten && !m.archived && !m.session_disposed_at && m.type !== "summary")
86
+ .filter((m) => (service.getMemoryTags?.(m.id) ?? []).length === 0)
87
+ .sort((a, b) => {
88
+ const ta = String(a.updated_at ?? "");
89
+ const tb = String(b.updated_at ?? "");
90
+ if (ta < tb) return 1;
91
+ if (ta > tb) return -1;
92
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
93
+ })
94
+ .slice(0, Math.max(1, maxPerRun));
95
+ if (!memories.length) return { ok: true, tagged: 0, skipped: 0, failed: 0, skippedBy: "empty" };
96
+ const tagRoute = resolveTagRoute(route, config, logger);
97
+ if (!tagRoute) return { ok: false, tagged: 0, skipped: memories.length, failed: 0, skippedBy: "no-route" };
98
+
99
+ const listText = memories
100
+ .map((m) => `id=${m.id} | title=${m.title} | content=${m.content}`)
101
+ .join("\n");
102
+ const text = await streamText(ctx, {
103
+ provider: tagRoute.provider,
104
+ model: tagRoute.model,
105
+ purpose: "compaction",
106
+ maxTokens: Math.min(2048, config?.dreamMaxTokens ?? 2048),
107
+ messages: [
108
+ { role: "system", content: [{ type: "text", text: TAG_PROMPT }] },
109
+ { role: "user", content: [{ type: "text", text: listText }] }
110
+ ]
111
+ });
112
+ if (text === undefined) return { ok: false, tagged: 0, skipped: memories.length, failed: 0, skippedBy: "llm-failed" };
113
+
114
+ const entries = parseJsonArray(text);
115
+ if (!entries) {
116
+ logger?.warn?.("dsh-mneme auto-tag: no json array in llm output");
117
+ return { ok: false, tagged: 0, skipped: memories.length, failed: 0, skippedBy: "bad-json" };
118
+ }
119
+
120
+ // Validate ids against the candidate set (unknown ids are ignored, never
121
+ // written — a stray id could otherwise tag an unrelated memory).
122
+ const candidateIds = new Set(memories.map((m) => m.id));
123
+ const toWrite = [];
124
+ let skipped = 0;
125
+ let failed = 0;
126
+ const seenIds = new Set();
127
+ for (const entry of entries) {
128
+ if (!entry || typeof entry !== "object") { failed++; continue; }
129
+ const id = typeof entry.id === "string" ? entry.id : undefined;
130
+ if (!id || !candidateIds.has(id)) { failed++; continue; }
131
+ if (seenIds.has(id)) continue; // first entry per id wins
132
+ seenIds.add(id);
133
+ // Reuse the shared sanitizer: strip, ≤20 chars, drop illegal, dedupe.
134
+ const tags = sanitizeTags(entry.tags);
135
+ if (!tags.length) { skipped++; continue; }
136
+ toWrite.push({ id, tags });
137
+ }
138
+
139
+ // Write the whole batch under one transaction: mirror re-renders once.
140
+ let tagged = 0;
141
+ try {
142
+ service.transaction(() => {
143
+ for (const { id, tags } of toWrite) {
144
+ // Merge with existing live tags (never clobber manual tags that landed
145
+ // between selection and write).
146
+ const existing = service.getMemoryTags?.(id) ?? [];
147
+ service.applyMemoryTags(id, [...new Set([...existing, ...tags])]);
148
+ tagged++;
149
+ }
150
+ });
151
+ } catch (error) {
152
+ logger?.warn?.(`dsh-mneme auto-tag: write failed: ${String(error)}`);
153
+ return { ok: false, tagged, skipped, failed, skippedBy: "write-failed" };
154
+ }
155
+ return { ok: true, tagged, skipped, failed, skippedBy: false };
156
+ }