@modusensus/dsh-mneme 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/lib/api.js +101 -0
- package/lib/client.js +129 -4
- package/lib/config.js +9 -0
- package/lib/parser/wiki-link.js +38 -0
- package/lib/service.js +79 -0
- package/lib/store.js +80 -2
- package/package.json +1 -1
- package/src/api.js +101 -0
- package/src/config.js +9 -0
- package/src/parser/wiki-link.js +38 -0
- package/src/service.js +79 -0
- package/src/store.js +80 -2
- package/test/client.test.js +36 -0
- package/test/wiki-link.test.js +332 -0
package/README.md
CHANGED
|
@@ -112,6 +112,10 @@ dsh web
|
|
|
112
112
|
- **幂等 + 熔断**:dispose/restore 状态守卫幂等(重复调用 no-op);事件回调内部异常 catch 住,不抛进 DSH 会话清理流程
|
|
113
113
|
- **恢复**:整会话 `service.restoreBySession(sessionId)` 一键还原;`service.listBySession(sessionId, { includeDisposed: true })` 可查看当前隐藏了哪些(DTO 带 `disposed` 标记)
|
|
114
114
|
|
|
115
|
+
### Wiki-Link 双向链接 🔗(v0.6.1,opt-in)
|
|
116
|
+
|
|
117
|
+
**默认关闭**(`wikiLinkEnabled: false`)。开启后,记忆正文支持 `[[target]]` / `[[显示|target]]` 双括号链接语法:保存时自动解析并记录跨记忆 `links_to` 关系(partial 唯一索引只对 links_to 去重),记忆详情旁出现反向链接面板,点击可跳转来源记忆。提供只读 API:backlinks / forward-links / wikilink-resolve(输出脱敏)。
|
|
118
|
+
|
|
115
119
|
|
|
116
120
|
|
|
117
121
|
官方设置面板 → 「记忆库设置」→「记忆」标签:按类型浏览、全文搜索;启用向量搜索后可用「语义」切换做向量召回。
|
|
@@ -212,6 +216,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
|
|
|
212
216
|
|
|
213
217
|
| 版本 | 亮点 |
|
|
214
218
|
|------|------|
|
|
219
|
+
| **v0.6.1** | Wiki-Link 双向链接(笔记化记忆库第一步):[[笔记]] 跨记忆链接 + 反向链接面板 + links_to partial 唯一索引;654 测试全绿 |
|
|
215
220
|
| **v0.6.0** | 会话生命周期(把对话当存档点):`session_disposed_at` 独立字段软隐藏会话删除的记忆(与 `archived` 正交,可恢复)+ `memory_delete` 支持描述删除 + 事件订阅熔断;阿里云 kimi-k2.7-code 审查 4 项修复;628 测试全绿 |
|
|
216
221
|
| **v0.5.0** | 主区「记忆库」视图(取代侧边栏抽屉)+ 记忆图谱可视化(ego-graph API + 零依赖 SVG 力导向)+ BM25 三路召回融合 + 自适应阈值 + 会话热记忆 + 召回基准评测;593 测试全绿 |
|
|
217
222
|
| **v0.4.2** | autoSummarize 自定义模型:`summarizeProvider`/`summarizeModel` 配置项,可独立指定轻量模型(如 qwen3.6-plus)用于会话摘要,节省主模型 token;473 测试全绿 |
|
|
@@ -239,6 +244,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
|
|
|
239
244
|
| **v0.4.7** | ✅ 完成 | schema 迁移幂等化 | 并发打开同一 db 时 `PRAGMA table_info` 检查与 ALTER 非原子,可能重复 `ADD COLUMN` 报 duplicate column name;改用 `addColumn` helper 吞掉竞态(try/catch),12 处迁移统一收口 |
|
|
240
245
|
| **v0.5.0** | ✅ 完成 | 召回融合与记忆可视化 | 主区「记忆库」视图 + 记忆图谱(ego-graph API + 零依赖 SVG 力导向)+ BM25 三路召回融合 + 自适应阈值 + 会话热记忆 + 召回基准;593 测试全绿 |
|
|
241
246
|
| **v0.6.0** | ✅ 完成 | 会话生命周期 | 把对话当存档点:`session_disposed_at` 软隐藏会话删除的记忆(与 `archived` 正交、可恢复)+ `memory_delete` 描述删除 + 事件熔断;628 测试全绿 |
|
|
247
|
+
| **v0.6.1** | ✅ 完成 | Wiki-Link 双向链接 | 笔记化记忆库第一步:`[[target]]` 跨记忆链接 + 反向链接面板 + links_to partial 唯一索引;654 测试全绿 |
|
|
242
248
|
| **v0.7.0+** | 🚀 远期 | 自进化记忆 | 兴趣漂移跟踪 + 跨 workspace 记忆共享(等 DSH 支持) |
|
|
243
249
|
|
|
244
250
|
> 新能力一律做成**可开关的功能**(配置启用/关闭),默认保守开启、不破坏现有行为。`failure_memories` 表与 autoDream 决策引擎已为后续反思性成长铺好路。
|
|
@@ -402,7 +408,7 @@ src/
|
|
|
402
408
|
lib/
|
|
403
409
|
├── client.js # Web 面板(手写 ModuleLoader bundle)
|
|
404
410
|
└── *.js # src 的同步分发产物
|
|
405
|
-
test/ #
|
|
411
|
+
test/ # 628 个 node:test 测试(含审计与三轴线压测不变量)
|
|
406
412
|
scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · benchmark-recall.js 召回基准
|
|
407
413
|
```
|
|
408
414
|
|
|
@@ -411,7 +417,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
|
|
|
411
417
|
```bash
|
|
412
418
|
cd dsh-mneme
|
|
413
419
|
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
|
|
414
|
-
npm test # 运行
|
|
420
|
+
npm test # 运行 628 个测试
|
|
415
421
|
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
|
|
416
422
|
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
|
|
417
423
|
```
|
package/lib/api.js
CHANGED
|
@@ -422,6 +422,107 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
422
422
|
}
|
|
423
423
|
});
|
|
424
424
|
|
|
425
|
+
// --- wiki-link back links (v0.6.1) --------------------------------------
|
|
426
|
+
// Read-only like the graph endpoints, so it stays open when apiToken is set.
|
|
427
|
+
// GET /api/dsh-mneme/wikilinks/backlinks?id=<memoryId> → memories whose
|
|
428
|
+
// content carries a [[wiki-link]] resolving to the given memory.
|
|
429
|
+
register({
|
|
430
|
+
kind: "exact",
|
|
431
|
+
path: "/api/dsh-mneme/wikilinks/backlinks",
|
|
432
|
+
handler(req, res) {
|
|
433
|
+
try {
|
|
434
|
+
const url = new URL(req.url, "http://localhost");
|
|
435
|
+
const id = (url.searchParams.get("id") ?? "").trim();
|
|
436
|
+
if (!id) {
|
|
437
|
+
sendJson(res, 400, { error: "missing-id" });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const memory = service.getById?.(id) ?? null;
|
|
441
|
+
const backlinks = (service.getBacklinks?.(id) ?? []).map(({ source, relation }) => ({
|
|
442
|
+
id: source.id,
|
|
443
|
+
title: source.title,
|
|
444
|
+
type: source.type,
|
|
445
|
+
created_at: relation.created_at
|
|
446
|
+
}));
|
|
447
|
+
sendJson(res, 200, {
|
|
448
|
+
memoryId: id,
|
|
449
|
+
memory: memory ? { id: memory.id, title: memory.title, type: memory.type } : null,
|
|
450
|
+
backlinks
|
|
451
|
+
});
|
|
452
|
+
} catch {
|
|
453
|
+
sendJson(res, 500, { error: "internal" });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// --- wiki-link forward links (v0.6.1) -----------------------------------
|
|
459
|
+
// GET /api/dsh-mneme/wikilinks/forward?id=<memoryId> → memories the given
|
|
460
|
+
// memory explicitly links to. Unresolved target titles surface with id:null.
|
|
461
|
+
register({
|
|
462
|
+
kind: "exact",
|
|
463
|
+
path: "/api/dsh-mneme/wikilinks/forward",
|
|
464
|
+
handler(req, res) {
|
|
465
|
+
try {
|
|
466
|
+
const url = new URL(req.url, "http://localhost");
|
|
467
|
+
const id = (url.searchParams.get("id") ?? "").trim();
|
|
468
|
+
if (!id) {
|
|
469
|
+
sendJson(res, 400, { error: "missing-id" });
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const memory = service.getById?.(id) ?? null;
|
|
473
|
+
const links = (service.getForwardLinks?.(id) ?? []).map(({ target, relation }) => ({
|
|
474
|
+
id: target?.id ?? null,
|
|
475
|
+
title: target?.title ?? relation.to_entity,
|
|
476
|
+
type: target?.type ?? null,
|
|
477
|
+
created_at: relation.created_at
|
|
478
|
+
}));
|
|
479
|
+
sendJson(res, 200, {
|
|
480
|
+
memoryId: id,
|
|
481
|
+
memory: memory ? { id: memory.id, title: memory.title, type: memory.type } : null,
|
|
482
|
+
links
|
|
483
|
+
});
|
|
484
|
+
} catch {
|
|
485
|
+
sendJson(res, 500, { error: "internal" });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// --- wiki-link resolve (v0.6.1) -----------------------------------------
|
|
491
|
+
// GET /api/dsh-mneme/wikilinks/resolve?title=<title> → case-insensitive exact
|
|
492
|
+
// title match against the memories table (the resolution used when writing
|
|
493
|
+
// links_to relations). 404 when no memory matches.
|
|
494
|
+
register({
|
|
495
|
+
kind: "exact",
|
|
496
|
+
path: "/api/dsh-mneme/wikilinks/resolve",
|
|
497
|
+
handler(req, res) {
|
|
498
|
+
try {
|
|
499
|
+
const url = new URL(req.url, "http://localhost");
|
|
500
|
+
const title = (url.searchParams.get("title") ?? "").trim();
|
|
501
|
+
if (!title) {
|
|
502
|
+
sendJson(res, 400, { error: "missing-title" });
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const memory = service.resolveWikiLink?.(title) ?? null;
|
|
506
|
+
if (!memory) {
|
|
507
|
+
sendJson(res, 404, { error: "memory-not-found", title });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
// Note: no `source` field — it may carry file paths/internal host info
|
|
511
|
+
// and this endpoint is read-only without auth when apiToken is set.
|
|
512
|
+
sendJson(res, 200, {
|
|
513
|
+
title,
|
|
514
|
+
memory: {
|
|
515
|
+
id: memory.id,
|
|
516
|
+
title: memory.title,
|
|
517
|
+
type: memory.type
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
} catch {
|
|
521
|
+
sendJson(res, 500, { error: "internal" });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
425
526
|
// --- health: mirror sync state (F-NEW-03 / v0.3.6) ---
|
|
426
527
|
// Auth-gated; only returns a sanitized error code (never raw last_error which
|
|
427
528
|
// may leak paths/token-like strings/internal hosts). On state read failure it
|
package/lib/client.js
CHANGED
|
@@ -142,7 +142,12 @@ window.__ModuleLoader__.load({
|
|
|
142
142
|
"memory.settings.apiTokenHint": "设置后,写操作与密钥接口(画像/规则/命令/向量配置)需携带 Authorization: Bearer <token>;面板只读操作不受影响。清空并保存可关闭鉴权。",
|
|
143
143
|
"memory.settings.apiTokenPlaceholder": "留空 = 不鉴权(默认)",
|
|
144
144
|
"memory.settings.apiTokenSave": "保存 Token",
|
|
145
|
-
"memory.settings.apiTokenSaved": "Token 已保存"
|
|
145
|
+
"memory.settings.apiTokenSaved": "Token 已保存",
|
|
146
|
+
"memory.wikilink.backlinks": "链接到此记忆",
|
|
147
|
+
"memory.wikilink.forward": "此记忆链接到",
|
|
148
|
+
"memory.wikilink.empty": "暂无关联记忆",
|
|
149
|
+
"memory.wikilink.loading": "加载中…",
|
|
150
|
+
"memory.wikilink.unresolved": "目标记忆不存在"
|
|
146
151
|
},
|
|
147
152
|
en: {
|
|
148
153
|
"memory.panel.empty": "No memories yet",
|
|
@@ -229,7 +234,12 @@ window.__ModuleLoader__.load({
|
|
|
229
234
|
"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
235
|
"memory.settings.apiTokenPlaceholder": "Empty = no auth (default)",
|
|
231
236
|
"memory.settings.apiTokenSave": "Save Token",
|
|
232
|
-
"memory.settings.apiTokenSaved": "Token saved"
|
|
237
|
+
"memory.settings.apiTokenSaved": "Token saved",
|
|
238
|
+
"memory.wikilink.backlinks": "Links to this memory",
|
|
239
|
+
"memory.wikilink.forward": "This memory links to",
|
|
240
|
+
"memory.wikilink.empty": "No linked memories",
|
|
241
|
+
"memory.wikilink.loading": "Loading…",
|
|
242
|
+
"memory.wikilink.unresolved": "Target memory not found"
|
|
233
243
|
}
|
|
234
244
|
};
|
|
235
245
|
|
|
@@ -333,6 +343,21 @@ window.__ModuleLoader__.load({
|
|
|
333
343
|
".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
344
|
".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
345
|
".mneme-xdactions{display:flex;gap:8px;margin-top:18px}",
|
|
346
|
+
// --- wiki-link backlinks panel (detail pane footer) ---
|
|
347
|
+
".mneme-backlinks{margin-top:18px;padding-top:14px;border-top:1px solid var(--dsw-alias-border-l1);display:flex;flex-direction:column;gap:12px}",
|
|
348
|
+
".mneme-bl-block{display:flex;flex-direction:column;gap:4px}",
|
|
349
|
+
".mneme-bl-head{font-size:12px;font-weight:500;color:var(--dsw-alias-label-tertiary)}",
|
|
350
|
+
".mneme-bl-list{display:flex;flex-wrap:wrap;gap:6px}",
|
|
351
|
+
".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}",
|
|
352
|
+
".mneme-bl-link:hover{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent)}",
|
|
353
|
+
".mneme-bl-link.mneme-bl-link--dim{color:var(--dsw-alias-label-tertiary);cursor:default}",
|
|
354
|
+
".mneme-bl-link.mneme-bl-link--dim:hover{background:none}",
|
|
355
|
+
".mneme-bl-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
356
|
+
".mneme-bl-meta{flex:none;font-size:11px;color:var(--dsw-alias-label-tertiary)}",
|
|
357
|
+
".mneme-bl-empty{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}",
|
|
358
|
+
// --- inline [[wiki-link]] links inside the detail content ---
|
|
359
|
+
".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}",
|
|
360
|
+
".mneme-wikilink:hover{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 8%,transparent)}",
|
|
336
361
|
// --- graph sub-view (fills the content area under the tabs) ---
|
|
337
362
|
".mneme-graph{flex:1;min-height:0;width:100%;display:flex;flex-direction:column;padding:12px 16px 16px;box-sizing:border-box}",
|
|
338
363
|
".mneme-graphbar{display:flex;gap:8px;align-items:center;flex:none;margin-bottom:10px}",
|
|
@@ -409,6 +434,92 @@ window.__ModuleLoader__.load({
|
|
|
409
434
|
});
|
|
410
435
|
}
|
|
411
436
|
|
|
437
|
+
// --- wiki-link (v0.6.1) -------------------------------------------------
|
|
438
|
+
// Splits detail content into literal text and clickable [[target]] /
|
|
439
|
+
// [[display|target]] links. Mirrors src/parser/wiki-link.js: single pipe
|
|
440
|
+
// only, empty / multi-pipe / unclosed markers stay literal text.
|
|
441
|
+
function wikilinkSegments(text, onClick) {
|
|
442
|
+
if (typeof text !== "string" || text.length === 0) return text;
|
|
443
|
+
const out = [];
|
|
444
|
+
let last = 0;
|
|
445
|
+
let key = 0;
|
|
446
|
+
const re = /\[\[([^\[\]]*)\]\]/g;
|
|
447
|
+
let m;
|
|
448
|
+
while ((m = re.exec(text)) !== null) {
|
|
449
|
+
const parts = m[1].split("|");
|
|
450
|
+
if (parts.length > 2) continue; // 多管道 → 非法,保留字面文本
|
|
451
|
+
const target = (parts.length === 2 ? parts[1] : parts[0]).trim();
|
|
452
|
+
if (!target) continue; // 空目标 → 非法
|
|
453
|
+
if (m.index > last) out.push(text.slice(last, m.index));
|
|
454
|
+
out.push(h("button", {
|
|
455
|
+
key: `w${key++}`,
|
|
456
|
+
type: "button",
|
|
457
|
+
className: "mneme-wikilink",
|
|
458
|
+
title: target,
|
|
459
|
+
onClick: () => onClick(target)
|
|
460
|
+
}, parts[0].trim() || target));
|
|
461
|
+
last = m.index + m[0].length;
|
|
462
|
+
}
|
|
463
|
+
if (last < text.length) out.push(text.slice(last));
|
|
464
|
+
return out.length ? out : text;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Backlinks panel: the two wiki-link relation blocks under the detail pane.
|
|
468
|
+
// Backlinks = memories whose content links to this one; forward = memories
|
|
469
|
+
// this one links to (unresolved targets surface as dim, non-clickable).
|
|
470
|
+
// Mounted per-selection (the surrounding Fragment keys on selected.id), so
|
|
471
|
+
// state resets and the fetches re-run on every switch.
|
|
472
|
+
function BacklinksPanel({ memory, t, onJump }) {
|
|
473
|
+
const [back, setBack] = useState(null); // null = loading
|
|
474
|
+
const [forward, setForward] = useState(null);
|
|
475
|
+
|
|
476
|
+
useEffect(() => {
|
|
477
|
+
let cancelled = false;
|
|
478
|
+
if (!memory || !memory.id) return;
|
|
479
|
+
apiFetch(`/api/dsh-mneme/wikilinks/backlinks?id=${encodeURIComponent(memory.id)}`)
|
|
480
|
+
.then((r) => (r.ok ? r.json() : { backlinks: [] }))
|
|
481
|
+
.then((j) => { if (!cancelled) setBack(Array.isArray(j.backlinks) ? j.backlinks : []); })
|
|
482
|
+
.catch(() => { if (!cancelled) setBack([]); });
|
|
483
|
+
apiFetch(`/api/dsh-mneme/wikilinks/forward?id=${encodeURIComponent(memory.id)}`)
|
|
484
|
+
.then((r) => (r.ok ? r.json() : { links: [] }))
|
|
485
|
+
.then((j) => { if (!cancelled) setForward(Array.isArray(j.links) ? j.links : []); })
|
|
486
|
+
.catch(() => { if (!cancelled) setForward([]); });
|
|
487
|
+
return () => { cancelled = true; };
|
|
488
|
+
}, [memory && memory.id]);
|
|
489
|
+
|
|
490
|
+
if (!memory || !memory.id) return null;
|
|
491
|
+
|
|
492
|
+
const loading = back === null || forward === null;
|
|
493
|
+
const row = (link, i) => {
|
|
494
|
+
const canJump = !!link.id;
|
|
495
|
+
return h("button", {
|
|
496
|
+
key: link.id ?? `unresolved-${i}`,
|
|
497
|
+
type: "button",
|
|
498
|
+
className: canJump ? "mneme-bl-link" : "mneme-bl-link mneme-bl-link--dim",
|
|
499
|
+
title: canJump ? t("memory.card.open") : t("memory.wikilink.unresolved"),
|
|
500
|
+
disabled: !canJump,
|
|
501
|
+
onClick: () => onJump && onJump({ id: link.id })
|
|
502
|
+
},
|
|
503
|
+
h("span", { className: "mneme-bl-title" }, link.title || "—"),
|
|
504
|
+
link.type && h("span", { className: "mneme-bl-meta" }, typeLabel(t, link.type))
|
|
505
|
+
);
|
|
506
|
+
};
|
|
507
|
+
const section = (label, items) =>
|
|
508
|
+
h("div", { className: "mneme-bl-block" },
|
|
509
|
+
h("div", { className: "mneme-bl-head" }, label),
|
|
510
|
+
loading
|
|
511
|
+
? h("div", { className: "mneme-bl-empty" }, t("memory.wikilink.loading"))
|
|
512
|
+
: items.length === 0
|
|
513
|
+
? h("div", { className: "mneme-bl-empty" }, t("memory.wikilink.empty"))
|
|
514
|
+
: h("div", { className: "mneme-bl-list" }, items.map(row))
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
return h("div", { className: "mneme-backlinks" },
|
|
518
|
+
section(t("memory.wikilink.backlinks"), back || []),
|
|
519
|
+
section(t("memory.wikilink.forward"), forward || [])
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
412
523
|
// --- Graph view: ego-graph of one entity, zero-dependency SVG force layout ---
|
|
413
524
|
// The DSH client module table only whitelists platform modules, so a graph
|
|
414
525
|
// library like vis-network cannot be required from a plugin. A hand-rolled
|
|
@@ -1083,6 +1194,17 @@ window.__ModuleLoader__.load({
|
|
|
1083
1194
|
setSelectedId(target.id);
|
|
1084
1195
|
};
|
|
1085
1196
|
|
|
1197
|
+
// [[wiki-link]] click in the detail content: resolve the title to a
|
|
1198
|
+
// memory (case-insensitive exact match) then jump to it. Unresolvable
|
|
1199
|
+
// titles silently no-op — the link stays as display text.
|
|
1200
|
+
const onWikilinkClick = (target) => {
|
|
1201
|
+
if (!target) return;
|
|
1202
|
+
apiFetch(`/api/dsh-mneme/wikilinks/resolve?title=${encodeURIComponent(target)}`)
|
|
1203
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
1204
|
+
.then((j) => { if (j?.memory?.id) jumpToMemory(j.memory); })
|
|
1205
|
+
.catch(() => {});
|
|
1206
|
+
};
|
|
1207
|
+
|
|
1086
1208
|
const openGraphFor = (name) => {
|
|
1087
1209
|
setGraphFocus(name || "");
|
|
1088
1210
|
setView("graph");
|
|
@@ -1174,11 +1296,14 @@ window.__ModuleLoader__.load({
|
|
|
1174
1296
|
Array.isArray(selected.tags) && selected.tags.length > 0 && h("div", { className: "mneme-xdmeta" },
|
|
1175
1297
|
h("span", null, `${t("memory.explorer.tags")}: ${selected.tags.join(" · ")}`)
|
|
1176
1298
|
),
|
|
1177
|
-
h("div", { className: "mneme-xdcontent" },
|
|
1299
|
+
h("div", { className: "mneme-xdcontent" },
|
|
1300
|
+
selected.content ? wikilinkSegments(selected.content, onWikilinkClick) : selected.content
|
|
1301
|
+
),
|
|
1178
1302
|
h("div", { className: "mneme-xdactions" },
|
|
1179
1303
|
h("button", { className: "mneme-footbtn", onClick: copyContent },
|
|
1180
1304
|
copied ? t("memory.explorer.copied") : t("memory.explorer.copy"))
|
|
1181
|
-
)
|
|
1305
|
+
),
|
|
1306
|
+
h(BacklinksPanel, { memory: selected, t, onJump: jumpToMemory })
|
|
1182
1307
|
)
|
|
1183
1308
|
)
|
|
1184
1309
|
: h("div", { className: "mneme-xempty" }, t("memory.explorer.emptyDetail"))
|
package/lib/config.js
CHANGED
|
@@ -166,6 +166,15 @@ 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
|
+
|
|
169
178
|
// --- sleep mode: idle-triggered deep maintenance (v0.4.0) ---------------
|
|
170
179
|
// Opt-in, off by default. Unlike autoDream (threshold-triggered, lightweight)
|
|
171
180
|
// sleep fires when the store has been quiet for sleepIdleMinutes and deep-
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wiki-Link parser (v0.6.1). Given a memory's content, extract explicit
|
|
3
|
+
* cross-memory links of the form [[target]] or [[显示|target]]:
|
|
4
|
+
* [[target]] → { display: "target", target: "target" }
|
|
5
|
+
* [[显示|target]] → { display: "显示", target: "target" }
|
|
6
|
+
*
|
|
7
|
+
* Unclosed / empty-target / multi-pipe / bracket-nested markers are treated as
|
|
8
|
+
* illegal and ignored. Pure module: no store, no side effects — resolution is a
|
|
9
|
+
* separate step (resolveWikiLink) that needs a store handle.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** @returns {{display: string, target: string}[]} in source order. */
|
|
13
|
+
export function parseWikiLinks(content) {
|
|
14
|
+
if (typeof content !== "string" || content.length === 0) return [];
|
|
15
|
+
const links = [];
|
|
16
|
+
// [^\[\]]* keeps a single match from crossing `]]`; an unclosed `[[` never
|
|
17
|
+
// matches, and `[[a [[b]] c]]` only yields the inner `[[b]]`.
|
|
18
|
+
const re = /\[\[([^\[\]]*)\]\]/g;
|
|
19
|
+
let m;
|
|
20
|
+
while ((m = re.exec(content)) !== null) {
|
|
21
|
+
const inner = m[1];
|
|
22
|
+
const parts = inner.split("|");
|
|
23
|
+
if (parts.length > 2) continue; // 多管道 → 非法,忽略
|
|
24
|
+
const rawTarget = (parts.length === 2 ? parts[1] : parts[0]).trim();
|
|
25
|
+
if (!rawTarget) continue; // 空目标 → 非法,忽略
|
|
26
|
+
const rawDisplay = parts[0].trim();
|
|
27
|
+
links.push({ display: rawDisplay || rawTarget, target: rawTarget });
|
|
28
|
+
}
|
|
29
|
+
return links;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Resolve a wiki-link target title to a memory row via a case-insensitive
|
|
33
|
+
* exact title match (store.findByTitle). Returns undefined when absent or the
|
|
34
|
+
* store exposes no such lookup. */
|
|
35
|
+
export function resolveWikiLink(store, title) {
|
|
36
|
+
if (!store || typeof title !== "string" || !title.trim()) return undefined;
|
|
37
|
+
return store.findByTitle?.(title.trim());
|
|
38
|
+
}
|
package/lib/service.js
CHANGED
|
@@ -3,6 +3,7 @@ import { TYPE_FILE } from "./mirror.js";
|
|
|
3
3
|
import { evaluateMemoryQuality } from "./quality-filter.js";
|
|
4
4
|
import { createBM25Index } from "./search/bm25.js";
|
|
5
5
|
import { adaptiveThreshold } from "./search/adaptive.js";
|
|
6
|
+
import { parseWikiLinks } from "./parser/wiki-link.js";
|
|
6
7
|
|
|
7
8
|
const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
|
|
8
9
|
|
|
@@ -228,6 +229,36 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
228
229
|
}
|
|
229
230
|
}
|
|
230
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Fire-and-forget wiki-link resolution for a freshly saved/updated memory
|
|
234
|
+
* (v0.6.1). Opt-in via config.wikiLinkEnabled. Parses [[target]] /
|
|
235
|
+
* [[显示|target]] markers out of the memory content and writes links_to
|
|
236
|
+
* relations (idempotent via the unique relation index). Runs through
|
|
237
|
+
* service.enqueue so it serializes with autoDream/sleep and never overlaps
|
|
238
|
+
* another background pass. Fully fail-safe: parse/store errors are swallowed
|
|
239
|
+
* and logged, never a write failure.
|
|
240
|
+
*/
|
|
241
|
+
function scheduleWikiLinkResolve(memory) {
|
|
242
|
+
if (txDepth > 0) return; // deferred to the transaction's commit
|
|
243
|
+
if (!config.wikiLinkEnabled || !memory?.id) return;
|
|
244
|
+
try {
|
|
245
|
+
const links = parseWikiLinks(memory?.content ?? "");
|
|
246
|
+
if (!links.length) return;
|
|
247
|
+
const targets = [...new Set(links.map((l) => l.target).filter(Boolean))];
|
|
248
|
+
enqueue(() => {
|
|
249
|
+
try {
|
|
250
|
+
store.saveWikiLinks({ memoryId: memory.id, title: memory.title, targets });
|
|
251
|
+
} catch (err) {
|
|
252
|
+
logger?.warn?.("wiki link resolve failed:", err);
|
|
253
|
+
}
|
|
254
|
+
}).catch((err) => {
|
|
255
|
+
logger?.warn?.("wiki link resolve failed:", err);
|
|
256
|
+
});
|
|
257
|
+
} catch (err) {
|
|
258
|
+
logger?.warn?.("wiki link resolve failed:", err);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
231
262
|
/**
|
|
232
263
|
* Cross-encoder rerank over a candidate list (best effort). Reranker
|
|
233
264
|
* failures degrade to the original candidate order — reranking is an
|
|
@@ -802,6 +833,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
802
833
|
afterSync("write");
|
|
803
834
|
notifyWrite();
|
|
804
835
|
scheduleEmbed(result);
|
|
836
|
+
scheduleWikiLinkResolve(result);
|
|
805
837
|
return { action: "merged", memory: result };
|
|
806
838
|
}
|
|
807
839
|
const created = store.save({
|
|
@@ -821,6 +853,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
821
853
|
notifyWrite();
|
|
822
854
|
scheduleEmbed(result);
|
|
823
855
|
scheduleEntityExtraction(result);
|
|
856
|
+
scheduleWikiLinkResolve(result);
|
|
824
857
|
return { action: "created", memory: result };
|
|
825
858
|
}
|
|
826
859
|
|
|
@@ -1286,8 +1319,52 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1286
1319
|
}
|
|
1287
1320
|
}
|
|
1288
1321
|
|
|
1322
|
+
/**
|
|
1323
|
+
* Forward links (v0.6.1): memories the given memory explicitly links to via
|
|
1324
|
+
* [[wiki-links]] in its content. Reads the links_to relations whose
|
|
1325
|
+
* from_entity is this memory's title and resolves each to_entity back to a
|
|
1326
|
+
* memory row (case-insensitive title match). Returns [{ target, relation }];
|
|
1327
|
+
* a target title with no matching memory surfaces as { target: null }.
|
|
1328
|
+
*/
|
|
1329
|
+
function getForwardLinks(memoryId) {
|
|
1330
|
+
const memory = store.getById(memoryId);
|
|
1331
|
+
if (!memory) return [];
|
|
1332
|
+
const out = [];
|
|
1333
|
+
for (const rel of store.getRelations(memory.title) ?? []) {
|
|
1334
|
+
if (rel.relation_type !== "links_to" || rel.from_entity !== memory.title) continue;
|
|
1335
|
+
out.push({ target: store.findByTitle?.(rel.to_entity) ?? null, relation: rel });
|
|
1336
|
+
}
|
|
1337
|
+
return out;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Back links (v0.6.1): memories that explicitly link TO the given memory
|
|
1342
|
+
* (their content carries a wiki-link whose target resolves to this memory's
|
|
1343
|
+
* title). Reads the links_to relations whose to_entity is this memory's
|
|
1344
|
+
* title; the linking memory is rel.memory_id (the source that wrote the
|
|
1345
|
+
* relation). Deduped per source memory; missing/self links are dropped.
|
|
1346
|
+
* Returns [{ source, relation }].
|
|
1347
|
+
*/
|
|
1348
|
+
function getBacklinks(memoryId) {
|
|
1349
|
+
const memory = store.getById(memoryId);
|
|
1350
|
+
if (!memory) return [];
|
|
1351
|
+
const out = [];
|
|
1352
|
+
const seen = new Set();
|
|
1353
|
+
for (const rel of store.getRelations(memory.title) ?? []) {
|
|
1354
|
+
if (rel.relation_type !== "links_to" || rel.to_entity !== memory.title) continue;
|
|
1355
|
+
const source = rel.memory_id ? store.getById(rel.memory_id) : undefined;
|
|
1356
|
+
if (!source || source.id === memory.id || seen.has(source.id)) continue;
|
|
1357
|
+
seen.add(source.id);
|
|
1358
|
+
out.push({ source, relation: rel });
|
|
1359
|
+
}
|
|
1360
|
+
return out;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1289
1363
|
return {
|
|
1290
1364
|
saveWithDedupe,
|
|
1365
|
+
getBacklinks,
|
|
1366
|
+
getForwardLinks,
|
|
1367
|
+
resolveWikiLink: (title) => store.findByTitle?.(title),
|
|
1291
1368
|
recoverMirror,
|
|
1292
1369
|
getMirrorHealth,
|
|
1293
1370
|
getMirrorState: () => store.getMirrorState(),
|
|
@@ -1393,6 +1470,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1393
1470
|
const sync = afterSync("write");
|
|
1394
1471
|
notifyWrite();
|
|
1395
1472
|
scheduleEmbed(updated);
|
|
1473
|
+
scheduleWikiLinkResolve(updated);
|
|
1396
1474
|
// Audit peer B: when the mirror sync failed, the store write landed but
|
|
1397
1475
|
// the mirror did not converge — return an explicit degraded receipt rather
|
|
1398
1476
|
// than a plain success. Non-enumerable so existing deepEqual assertions on
|
|
@@ -1504,6 +1582,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1504
1582
|
// migrates entity_attrs on merge. Bookkeeping writes like the audit
|
|
1505
1583
|
// passthroughs above — never write-hook-triggering memory mutations.
|
|
1506
1584
|
saveRelation: (r) => store.saveRelation(r),
|
|
1585
|
+
saveWikiLinks: (r) => store.saveWikiLinks(r),
|
|
1507
1586
|
listEntities: (o) => store.listEntities(o),
|
|
1508
1587
|
getRelations: (id) => store.getRelations(id),
|
|
1509
1588
|
saveAttr: (r) => store.saveAttr(r),
|
package/lib/store.js
CHANGED
|
@@ -552,6 +552,17 @@ export function createStore(path) {
|
|
|
552
552
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
553
553
|
db.exec(SCHEMA);
|
|
554
554
|
|
|
555
|
+
// Wiki-link dedup (v0.6.1): a (from_entity, to_entity) pair is unique only for
|
|
556
|
+
// relation_type='links_to'. This is a PARTIAL index scoped to links_to, so the
|
|
557
|
+
// append-only semantics of all other relation types (uses/depends_on/part_of/
|
|
558
|
+
// related_to/supersedes — the extractor and autoDream write these per-run
|
|
559
|
+
// without global dedup, and supersedes rows carry distinct metadata like
|
|
560
|
+
// attr_key/old_value) are preserved. Idempotent (IF NOT EXISTS), atomic, and
|
|
561
|
+
// race-safe. Legacy DBs have no links_to rows yet, so the index builds cleanly
|
|
562
|
+
// everywhere and never breaks plugin startup (a full-table UNIQUE index would
|
|
563
|
+
// fail on legacy duplicates).
|
|
564
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_relations_wikilink ON entity_relations(from_entity, to_entity, relation_type) WHERE relation_type = 'links_to'");
|
|
565
|
+
|
|
555
566
|
// Schema migrations for legacy databases (idempotent). Each ADD COLUMN is
|
|
556
567
|
// also race-safe: two concurrently-opening processes can both pass the
|
|
557
568
|
// PRAGMA table_info check before either ALTERs, so the ALTER itself is
|
|
@@ -652,6 +663,19 @@ export function createStore(path) {
|
|
|
652
663
|
return toRow(row);
|
|
653
664
|
}
|
|
654
665
|
|
|
666
|
+
/**
|
|
667
|
+
* Case-insensitive exact title lookup (v0.6.1 wiki-link). COLLATE NOCASE
|
|
668
|
+
* folds ASCII case (CJK titles are inherently case-free, so they match
|
|
669
|
+
* verbatim). Returns the first matching memory or undefined. Best-effort —
|
|
670
|
+
* used by wiki-link target resolution and the read APIs.
|
|
671
|
+
*/
|
|
672
|
+
function findByTitle(title) {
|
|
673
|
+
if (typeof title !== "string" || !title.trim()) return undefined;
|
|
674
|
+
return toRow(db.prepare(
|
|
675
|
+
"SELECT * FROM memories WHERE title = ? COLLATE NOCASE LIMIT 1"
|
|
676
|
+
).get(title.trim()));
|
|
677
|
+
}
|
|
678
|
+
|
|
655
679
|
function save(memory) {
|
|
656
680
|
const id = memory.id ?? randomUUID();
|
|
657
681
|
const type = memory.type;
|
|
@@ -1662,7 +1686,10 @@ export function createStore(path) {
|
|
|
1662
1686
|
|
|
1663
1687
|
/**
|
|
1664
1688
|
* Record a typed relation between two entities. metadata (optional) is a
|
|
1665
|
-
* free-form JSON blob describing the relation. Relations are append-only
|
|
1689
|
+
* free-form JSON blob describing the relation. Relations are append-only —
|
|
1690
|
+
* callers that need idempotency (e.g. wiki-links, via saveWikiLinks) guard
|
|
1691
|
+
* with their own existence check plus the partial links_to unique index
|
|
1692
|
+
* (idx_relations_wikilink) as a race backstop.
|
|
1666
1693
|
*/
|
|
1667
1694
|
function saveRelation({ from_entity, to_entity, relation_type, memory_id, metadata }) {
|
|
1668
1695
|
const id = randomUUID();
|
|
@@ -1676,7 +1703,56 @@ export function createStore(path) {
|
|
|
1676
1703
|
`INSERT INTO entity_relations (id, from_entity, to_entity, relation_type, memory_id, created_at, metadata)
|
|
1677
1704
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
1678
1705
|
).run(id, from_entity, to_entity, relation_type, memory_id ?? null, now, metaStr);
|
|
1679
|
-
return toRelation(db.prepare(
|
|
1706
|
+
return toRelation(db.prepare(
|
|
1707
|
+
"SELECT * FROM entity_relations WHERE from_entity = ? AND to_entity = ? AND relation_type = ? LIMIT 1"
|
|
1708
|
+
).get(from_entity, to_entity, relation_type));
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
/**
|
|
1712
|
+
* Record wiki-link relations (v0.6.1). For each target title, resolve the
|
|
1713
|
+
* target memory (case-insensitive title match via findByTitle) and write a
|
|
1714
|
+
* links_to relation:
|
|
1715
|
+
* from_entity = source memory title, to_entity = canonical target memory
|
|
1716
|
+
* title, relation_type = 'links_to', memory_id = source memory id.
|
|
1717
|
+
* Using the canonical resolved title keeps the graph case-consistent
|
|
1718
|
+
* ([[beta]] and [[Beta]] collapse onto the same to_entity), so backlink
|
|
1719
|
+
* lookups never fight the way a target was typed.
|
|
1720
|
+
* Fail-safe: a target with no matching memory is skipped (never an error).
|
|
1721
|
+
* Idempotent: an already-existing triple is a silent no-op (existence check
|
|
1722
|
+
* here + the idx_relations_wikilink partial unique index as a race backstop),
|
|
1723
|
+
* so `saved` only counts newly written relations. Returns { saved, skipped }.
|
|
1724
|
+
*/
|
|
1725
|
+
function saveWikiLinks({ memoryId, title, targets }) {
|
|
1726
|
+
const saved = [];
|
|
1727
|
+
const skipped = [];
|
|
1728
|
+
const seen = new Set(); // canonical (lowercased) targets already handled
|
|
1729
|
+
const existsStmt = db.prepare(
|
|
1730
|
+
"SELECT id FROM entity_relations WHERE from_entity = ? AND to_entity = ? AND relation_type = ?"
|
|
1731
|
+
);
|
|
1732
|
+
const list = Array.isArray(targets)
|
|
1733
|
+
? targets.filter((t) => typeof t === "string" && t.trim())
|
|
1734
|
+
: [];
|
|
1735
|
+
for (const raw of list) {
|
|
1736
|
+
const target = raw.trim();
|
|
1737
|
+
const key = target.toLowerCase();
|
|
1738
|
+
if (seen.has(key)) continue; // dedupe within a single call (case-insensitive)
|
|
1739
|
+
seen.add(key);
|
|
1740
|
+
const targetMem = findByTitle(target);
|
|
1741
|
+
if (!targetMem) {
|
|
1742
|
+
skipped.push(target); // 目标不存在 → 跳过(Fail-safe)
|
|
1743
|
+
continue;
|
|
1744
|
+
}
|
|
1745
|
+
const toEntity = targetMem.title;
|
|
1746
|
+
if (existsStmt.get(title, toEntity, "links_to")) continue; // already linked → no-op
|
|
1747
|
+
saved.push(saveRelation({
|
|
1748
|
+
from_entity: title,
|
|
1749
|
+
to_entity: toEntity,
|
|
1750
|
+
relation_type: "links_to",
|
|
1751
|
+
memory_id: memoryId,
|
|
1752
|
+
metadata: { target_memory_id: targetMem.id }
|
|
1753
|
+
}));
|
|
1754
|
+
}
|
|
1755
|
+
return { saved, skipped };
|
|
1680
1756
|
}
|
|
1681
1757
|
|
|
1682
1758
|
/**
|
|
@@ -1980,6 +2056,8 @@ export function createStore(path) {
|
|
|
1980
2056
|
getAttrsByMemory,
|
|
1981
2057
|
findMemoriesByAttr,
|
|
1982
2058
|
saveRelation,
|
|
2059
|
+
saveWikiLinks,
|
|
2060
|
+
findByTitle,
|
|
1983
2061
|
migrateAttrsToMemory,
|
|
1984
2062
|
getRelations,
|
|
1985
2063
|
setMirrorState,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
3
|
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.1",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|