@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 +17 -0
- package/lib/api.js +77 -1
- package/lib/client.js +234 -5
- package/lib/config.js +18 -0
- package/lib/dream/tag-extractor.js +156 -0
- package/lib/dream.js +20 -0
- package/lib/mirror.js +9 -0
- package/lib/parser/tag.js +59 -0
- package/lib/search/tag-boost.js +61 -0
- package/lib/service.js +134 -4
- package/lib/store.js +162 -0
- package/package.json +1 -1
- package/src/api.js +77 -1
- package/src/config.js +18 -0
- package/src/dream/tag-extractor.js +156 -0
- package/src/dream.js +20 -0
- package/src/mirror.js +9 -0
- package/src/parser/tag.js +59 -0
- package/src/search/tag-boost.js +61 -0
- package/src/service.js +134 -4
- package/src/store.js +162 -0
- package/test/api.test.js +80 -0
- package/test/boundary-v0625.test.js +82 -0
- package/test/client.test.js +98 -0
- package/test/directory.test.js +134 -0
- package/test/tag-boost.test.js +125 -0
- package/test/tag.test.js +312 -0
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:
|
|
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
|
-
|
|
1297
|
-
h("span", null,
|
|
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
|
+
}
|