@modusensus/dsh-mneme 0.5.3 → 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 +27 -6
- package/lib/api.js +101 -0
- package/lib/client.js +129 -4
- package/lib/config.js +14 -0
- package/lib/dream/sleep.js +4 -4
- package/lib/dream.js +2 -2
- package/lib/index.js +20 -0
- package/lib/parser/wiki-link.js +38 -0
- package/lib/service.js +108 -1
- package/lib/store.js +145 -8
- package/lib/tools.js +16 -5
- package/package.json +1 -1
- package/src/api.js +101 -0
- package/src/config.js +14 -0
- package/src/dream/sleep.js +4 -4
- package/src/dream.js +2 -2
- package/src/index.js +20 -0
- package/src/parser/wiki-link.js +38 -0
- package/src/service.js +108 -1
- package/src/store.js +145 -8
- package/src/tools.js +16 -5
- package/test/client.test.js +36 -0
- package/test/service.test.js +106 -0
- package/test/store.test.js +76 -0
- package/test/tools.test.js +20 -0
- package/test/wiki-link.test.js +332 -0
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
|
8
|
-
[](https://github.com/modusensus/dsh-mneme)
|
|
9
9
|
|
|
10
10
|
> 给 DeepSeek Harness 的跨会话记忆插件:让 Agent 记住你、记住项目、自动整理记忆。**Mneme**(Μνήμη)——希腊记忆女神 Mnemosyne 之名,掌管记忆与梦境,正如 autoDream 在后台巩固记忆。
|
|
11
11
|
|
|
@@ -30,6 +30,7 @@ dsh web
|
|
|
30
30
|
- 不是向量数据库——语义搜索是可选增强,默认零额外依赖
|
|
31
31
|
- 不替代会话日志——它存的是「值得跨会话记住的」精炼知识
|
|
32
32
|
- 不改变模型本身——进化的是记忆库与每次注入的上下文
|
|
33
|
+
- 删对话 ≠ 删记忆——开启会话生命周期后,删除会话只是把该会话出生的记忆**软隐藏**(可恢复),数据不丢
|
|
33
34
|
|
|
34
35
|
## ✨ 功能
|
|
35
36
|
|
|
@@ -52,7 +53,7 @@ dsh web
|
|
|
52
53
|
| `memory_search` | 全文搜索(中文子串友好,可启用向量语义搜索) |
|
|
53
54
|
| `memory_list` | 按类型分页列出(`include_archived=true` 可查看已归档) |
|
|
54
55
|
| `memory_update` | 修改已有记忆 |
|
|
55
|
-
| `memory_delete` |
|
|
56
|
+
| `memory_delete` | 删除记忆(v0.6.0 支持 `query` 按描述删除,不只能靠记忆 ID) |
|
|
56
57
|
| `memory_forget` | 抑制注入(降权不删除,可恢复) |
|
|
57
58
|
| `memory_archive` | 归档/恢复记忆(v0.2.5;归档后隐藏于列表/搜索/注入/整理,`archived=false` 可恢复) |
|
|
58
59
|
|
|
@@ -102,7 +103,20 @@ dsh web
|
|
|
102
103
|
|
|
103
104
|
> 配置详见 `docs/SLEEP.md`;迁移说明见 `docs/MIGRATION.md`。
|
|
104
105
|
|
|
105
|
-
###
|
|
106
|
+
### 会话生命周期:把对话当存档点 💾(v0.6.0,opt-in)
|
|
107
|
+
|
|
108
|
+
**默认关闭**(`sessionLifecycleEnabled: false`,保持旧行为)。开启后,会话被删除/销毁(DSH `session/disposed` 事件)时,自动把该会话内出生(`session_id` 溯源)的记忆**软隐藏**——不再出现在检索/注入/列表/整理,但**不删除**,随时可恢复:
|
|
109
|
+
|
|
110
|
+
- **与 `archived` 正交**:`archived` 是用户/AI 主动"长期保留但安静",`session_disposed_at` 是会话删除被动隔离,两者互不覆盖。恢复会话绝不复活你手动归档的记忆
|
|
111
|
+
- **全局记忆免疫**:存量无 `session_id` 的记忆视为全局,永不参与会话清理
|
|
112
|
+
- **幂等 + 熔断**:dispose/restore 状态守卫幂等(重复调用 no-op);事件回调内部异常 catch 住,不抛进 DSH 会话清理流程
|
|
113
|
+
- **恢复**:整会话 `service.restoreBySession(sessionId)` 一键还原;`service.listBySession(sessionId, { includeDisposed: true })` 可查看当前隐藏了哪些(DTO 带 `disposed` 标记)
|
|
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
|
+
|
|
119
|
+
|
|
106
120
|
|
|
107
121
|
官方设置面板 → 「记忆库设置」→「记忆」标签:按类型浏览、全文搜索;启用向量搜索后可用「语义」切换做向量召回。
|
|
108
122
|
|
|
@@ -202,6 +216,8 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
|
|
|
202
216
|
|
|
203
217
|
| 版本 | 亮点 |
|
|
204
218
|
|------|------|
|
|
219
|
+
| **v0.6.1** | Wiki-Link 双向链接(笔记化记忆库第一步):[[笔记]] 跨记忆链接 + 反向链接面板 + links_to partial 唯一索引;654 测试全绿 |
|
|
220
|
+
| **v0.6.0** | 会话生命周期(把对话当存档点):`session_disposed_at` 独立字段软隐藏会话删除的记忆(与 `archived` 正交,可恢复)+ `memory_delete` 支持描述删除 + 事件订阅熔断;阿里云 kimi-k2.7-code 审查 4 项修复;628 测试全绿 |
|
|
205
221
|
| **v0.5.0** | 主区「记忆库」视图(取代侧边栏抽屉)+ 记忆图谱可视化(ego-graph API + 零依赖 SVG 力导向)+ BM25 三路召回融合 + 自适应阈值 + 会话热记忆 + 召回基准评测;593 测试全绿 |
|
|
206
222
|
| **v0.4.2** | autoSummarize 自定义模型:`summarizeProvider`/`summarizeModel` 配置项,可独立指定轻量模型(如 qwen3.6-plus)用于会话摘要,节省主模型 token;473 测试全绿 |
|
|
207
223
|
| **v0.4.0** | 系统级睡眠 Sleep Mode:空闲触发的四阶段深度维护(冲突消解 / 归档降级 / 模式发现 / 关系补全),可中断、串行安全、fail-safe,分层压缩释放冷记忆;471 测试全绿 |
|
|
@@ -227,7 +243,9 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
|
|
|
227
243
|
| **v0.4.6** | ✅ 完成 | 8 项修复(向量链路 + 注入/质量/审计) | 向量链路修复(embedSingle 适配 / `autoReindexOnBoot` 存量回填 / `vector_meta` 元数据)+ 注入语义召回 `hybridInject` + 同标题追加 `content_history` + 注入长度上限(单条 300 / 整块 1500)+ 记忆质量过滤 `memoryQualityFilter` + LLM 消耗审计 `llmAudit`(表 + 埋点 + 只读 API);553 测试全绿 |
|
|
228
244
|
| **v0.4.7** | ✅ 完成 | schema 迁移幂等化 | 并发打开同一 db 时 `PRAGMA table_info` 检查与 ALTER 非原子,可能重复 `ADD COLUMN` 报 duplicate column name;改用 `addColumn` helper 吞掉竞态(try/catch),12 处迁移统一收口 |
|
|
229
245
|
| **v0.5.0** | ✅ 完成 | 召回融合与记忆可视化 | 主区「记忆库」视图 + 记忆图谱(ego-graph API + 零依赖 SVG 力导向)+ BM25 三路召回融合 + 自适应阈值 + 会话热记忆 + 召回基准;593 测试全绿 |
|
|
230
|
-
| **v0.6.0
|
|
246
|
+
| **v0.6.0** | ✅ 完成 | 会话生命周期 | 把对话当存档点:`session_disposed_at` 软隐藏会话删除的记忆(与 `archived` 正交、可恢复)+ `memory_delete` 描述删除 + 事件熔断;628 测试全绿 |
|
|
247
|
+
| **v0.6.1** | ✅ 完成 | Wiki-Link 双向链接 | 笔记化记忆库第一步:`[[target]]` 跨记忆链接 + 反向链接面板 + links_to partial 唯一索引;654 测试全绿 |
|
|
248
|
+
| **v0.7.0+** | 🚀 远期 | 自进化记忆 | 兴趣漂移跟踪 + 跨 workspace 记忆共享(等 DSH 支持) |
|
|
231
249
|
|
|
232
250
|
> 新能力一律做成**可开关的功能**(配置启用/关闭),默认保守开启、不破坏现有行为。`failure_memories` 表与 autoDream 决策引擎已为后续反思性成长铺好路。
|
|
233
251
|
|
|
@@ -280,6 +298,8 @@ dsh web
|
|
|
280
298
|
dreamThresholdCount: 10
|
|
281
299
|
dreamThresholdChars: 5000
|
|
282
300
|
dreamDelayMs: 2000
|
|
301
|
+
# 会话生命周期(v0.6.0,默认关):会话被删时软隐藏其记忆,可恢复
|
|
302
|
+
# sessionLifecycleEnabled: false
|
|
283
303
|
```
|
|
284
304
|
|
|
285
305
|
## ⚙️ 配置
|
|
@@ -342,6 +362,7 @@ dsh web
|
|
|
342
362
|
| `searchSemanticDedupThreshold` | `0.95` | 语义去重相似度阈值(v0.5.0,默认 0.95,范围 0.5-1.0):`searchSemanticDedup=true` 时生效,调整可防小模型误折叠 |
|
|
343
363
|
| `memoryQualityFilter` | `{enabled:true, archiveThreshold:30, degradeThreshold:60, minContentLength:10}` | 记忆质量过滤(v0.4.6,默认开):写库前启发式打分 0-100,元记忆词汇/自指/过短/重复/近似重复扣分;≥60 正常存储,30-60 降权(注入排序按 importance×quality/100),<30 归档标记 `low_quality`(显式搜索仍可召回,永不自动注入) |
|
|
344
364
|
| `llmAudit` | `{enabled:true, retentionDays:90}` | LLM 消耗审计(v0.4.6,默认开):每次后台 LLM 调用(autoDream/autoSummarize)写 `llm_audit_logs`(tokens/duration/status/source);失败记 error 不阻塞;只读 API `/api/dsh-mneme/semantic/llm-audit` + `/llm-audit/stats` |
|
|
365
|
+
| `sessionLifecycleEnabled` | `false` | 会话生命周期(v0.6.0,默认关):开启后会话被删除/销毁时自动把该会话出生的记忆软隐藏(`session_disposed_at`,与 `archived` 正交、可恢复);存量无 `session_id` 的记忆永不参与清理 |
|
|
345
366
|
|
|
346
367
|
> 🔐 **API 安全**:DSH 无内置鉴权且默认仅监听 `127.0.0.1`。插件 API 默认开放(便于 Web 面板即装即用)。如需防护(如局域网暴露),在配置中设置 `apiToken`:写操作(画像/规则/命令)与密钥端点(`vector-config`、`vector-reindex`)需携带 `Authorization: Bearer <token>`(前端设置面板可填入同一 token),只读的 `list` / `search` / `semantic` 保持开放。`/api/dsh-mneme/vector-config` 返回的 `apiKey` 已掩码(`sk-***…`),存储仍保留明文供调用;前端回传空或掩码值表示"不改 key"。
|
|
347
368
|
|
|
@@ -387,7 +408,7 @@ src/
|
|
|
387
408
|
lib/
|
|
388
409
|
├── client.js # Web 面板(手写 ModuleLoader bundle)
|
|
389
410
|
└── *.js # src 的同步分发产物
|
|
390
|
-
test/ #
|
|
411
|
+
test/ # 628 个 node:test 测试(含审计与三轴线压测不变量)
|
|
391
412
|
scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · benchmark-recall.js 召回基准
|
|
392
413
|
```
|
|
393
414
|
|
|
@@ -396,7 +417,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
|
|
|
396
417
|
```bash
|
|
397
418
|
cd dsh-mneme
|
|
398
419
|
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
|
|
399
|
-
npm test # 运行
|
|
420
|
+
npm test # 运行 628 个测试
|
|
400
421
|
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
|
|
401
422
|
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
|
|
402
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
|
@@ -4,6 +4,11 @@ export const Config = z.object({
|
|
|
4
4
|
memoryDir: z.string().default("~/.dsh/memory"),
|
|
5
5
|
autoInject: z.boolean().default(true),
|
|
6
6
|
autoSummarize: z.boolean().default(true),
|
|
7
|
+
// Session lifecycle (v0.6.0): when enabled, deleting/disposing a session also
|
|
8
|
+
// archives every memory that was born in it (treating the session as a save
|
|
9
|
+
// point — entries stay recoverable via memory_archive/restoreBySession).
|
|
10
|
+
// Default OFF: legacy behavior, a disposed session leaves its memories active.
|
|
11
|
+
sessionLifecycleEnabled: z.boolean().default(false),
|
|
7
12
|
// Optional model override for summarization. When both are non-empty, they
|
|
8
13
|
// take priority over the session's current model. Empty = use the session's
|
|
9
14
|
// active provider/model (same as before).
|
|
@@ -161,6 +166,15 @@ export const Config = z.object({
|
|
|
161
166
|
// Prefix/semantic search over entity names (used by recall).
|
|
162
167
|
entitySearchEnabled: z.boolean().default(true),
|
|
163
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
|
+
|
|
164
178
|
// --- sleep mode: idle-triggered deep maintenance (v0.4.0) ---------------
|
|
165
179
|
// Opt-in, off by default. Unlike autoDream (threshold-triggered, lightweight)
|
|
166
180
|
// sleep fires when the store has been quiet for sleepIdleMinutes and deep-
|
package/lib/dream/sleep.js
CHANGED
|
@@ -108,7 +108,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
|
|
|
108
108
|
}
|
|
109
109
|
const strictness = config.sleepConflictStrictness ?? "normal";
|
|
110
110
|
const threshold = CONFLICT_THRESHOLDS[strictness] ?? CONFLICT_THRESHOLDS.normal;
|
|
111
|
-
const memories = service.all().filter((m) => !m.archived && !m.forgotten && m.type !== "summary");
|
|
111
|
+
const memories = service.all().filter((m) => !m.archived && !m.session_disposed_at && !m.forgotten && m.type !== "summary");
|
|
112
112
|
if (memories.length < 2) return { status: "skipped", reason: "too few memories" };
|
|
113
113
|
if (signal?.aborted) return { status: "aborted", reason: "user activity" };
|
|
114
114
|
|
|
@@ -239,7 +239,7 @@ function phaseDemotion(service, config, logger, runId, signal = null) {
|
|
|
239
239
|
const archived = [];
|
|
240
240
|
for (const m of service.all()) {
|
|
241
241
|
if (signal?.aborted) break;
|
|
242
|
-
if (m.archived || m.forgotten) continue;
|
|
242
|
+
if (m.archived || m.forgotten || m.session_disposed_at) continue;
|
|
243
243
|
const ref = m.last_accessed_at ?? m.updated_at ?? m.created_at;
|
|
244
244
|
if (!ref) continue;
|
|
245
245
|
const t = new Date(ref).getTime();
|
|
@@ -273,7 +273,7 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
|
|
|
273
273
|
const limit = config.sleepPatternMinMemories ?? 100;
|
|
274
274
|
const memories = service
|
|
275
275
|
.list({ limit: 200, includeForgotten: false })
|
|
276
|
-
.filter((m) => !m.archived && m.type !== "summary" && m.type !== "pattern")
|
|
276
|
+
.filter((m) => !m.archived && !m.session_disposed_at && m.type !== "summary" && m.type !== "pattern")
|
|
277
277
|
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1))
|
|
278
278
|
.slice(0, limit);
|
|
279
279
|
if (memories.length === 0) return { status: "skipped", reason: "no memories to scan" };
|
|
@@ -342,7 +342,7 @@ function phaseRelations(service, config, logger, runId, signal = null) {
|
|
|
342
342
|
if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
|
|
343
343
|
const orphans = entities.filter((e) => (service.getRelations(e.id) ?? []).length === 0);
|
|
344
344
|
if (orphans.length === 0) return { status: "skipped", reason: "no orphan entities" };
|
|
345
|
-
const memories = service.all().filter((m) => !m.archived && !m.forgotten);
|
|
345
|
+
const memories = service.all().filter((m) => !m.archived && !m.session_disposed_at && !m.forgotten);
|
|
346
346
|
const seen = new Set();
|
|
347
347
|
const related = [];
|
|
348
348
|
const MAX_RELATIONS_PER_ORPHAN = 3;
|
package/lib/dream.js
CHANGED
|
@@ -495,7 +495,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
495
495
|
let inFlight = null;
|
|
496
496
|
|
|
497
497
|
function shouldTrigger(service) {
|
|
498
|
-
const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
|
|
498
|
+
const memories = service.all().filter((m) => !m.archived && !m.session_disposed_at && m.type !== "summary");
|
|
499
499
|
const count = memories.length;
|
|
500
500
|
const chars = totalChars(memories);
|
|
501
501
|
const overBase = count >= baseline.count + thresholdCount || chars >= baseline.chars + thresholdChars;
|
|
@@ -844,7 +844,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
844
844
|
: {}),
|
|
845
845
|
messages: [
|
|
846
846
|
{ role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
|
|
847
|
-
{ role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
|
|
847
|
+
{ role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && !m.session_disposed_at && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
|
|
848
848
|
]
|
|
849
849
|
}, reportUsage));
|
|
850
850
|
} catch (error) {
|
package/lib/index.js
CHANGED
|
@@ -317,6 +317,26 @@ export const apply = (ctx, config) => {
|
|
|
317
317
|
const summarizer = createSummarizer(ctx, service, cfg);
|
|
318
318
|
disposers.push(summarizer.dispose);
|
|
319
319
|
|
|
320
|
+
// Session lifecycle (v0.6.0): when a session leaves the store and the toggle
|
|
321
|
+
// is enabled, mark every memory born in it as session-disposed (hidden from
|
|
322
|
+
// injection/search/dream but never destroyed — recoverable via
|
|
323
|
+
// restoreBySession). Default off, so a disposed session leaves its memories
|
|
324
|
+
// active (legacy behavior). Every path is guarded: a failure inside the
|
|
325
|
+
// callback must never propagate into DSH's session teardown (that would crash
|
|
326
|
+
// the plugin on the very delete action it serves).
|
|
327
|
+
if (cfg.sessionLifecycleEnabled) {
|
|
328
|
+
disposers.push(ctx.on("session/disposed", (session) => {
|
|
329
|
+
const sessionId = session?.id;
|
|
330
|
+
if (!sessionId) return;
|
|
331
|
+
try {
|
|
332
|
+
const { disposed } = service.disposeBySession(sessionId);
|
|
333
|
+
ctx.logger?.info?.(`[dsh-mneme] session disposed, hid ${disposed} memory(s) for ${sessionId}`);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
ctx.logger?.warn?.(`[dsh-mneme] session dispose failed for ${sessionId}: ${String(error)}`);
|
|
336
|
+
}
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
|
|
320
340
|
if (ctx.webServer) {
|
|
321
341
|
const api = createApi(ctx, service, settings, commands ?? {
|
|
322
342
|
add: () => { throw new Error("commands unavailable"); },
|
|
@@ -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
|
+
}
|