@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.
@@ -76,6 +76,10 @@ test("toApiList maps store rows to wire DTOs", () => {
76
76
  assert.equal(dto[0].title, "选型");
77
77
  assert.equal(dto[0].content, "node:sqlite");
78
78
  assert.equal(dto[0].importance, 3);
79
+ // session_id rides the DTO when present (session lifecycle needs it)
80
+ const withSession = service.saveWithDedupe({ type: "decision", title: "带会话", content: "y", session_id: "sess-x" });
81
+ const dto2 = service.toApiList([withSession.memory]);
82
+ assert.equal(dto2[0].session_id, "sess-x");
79
83
  });
80
84
 
81
85
  test("mergeHumanEdits skips edits without id and keeps applying the rest", () => {
@@ -327,3 +331,105 @@ test("Bug7: injection ranking re-weights by quality_score (degraded memories ran
327
331
  assert.equal(candidates[0].title, "高质量", "100-quality preference leads the degraded one");
328
332
  assert.equal(candidates[1].title, "元记忆");
329
333
  });
334
+
335
+ // --- session lifecycle (v0.6.0): dispose/restore by session -----------------
336
+ // session_disposed_at is orthogonal to `archived`: dispose hides only entries
337
+ // born in the deleted session, and restore never resurrects user-archived ones.
338
+
339
+ test("disposeBySession marks only that session's memories", () => {
340
+ const store = createStore(":memory:");
341
+ const svc = createService({ store, mirror: null, config: {} });
342
+ const s1 = svc.saveWithDedupe({ type: "project", title: "会话A记忆", content: "x", session_id: "sess-1" });
343
+ svc.saveWithDedupe({ type: "project", title: "会话B记忆", content: "y", session_id: "sess-2" });
344
+
345
+ const res = svc.disposeBySession("sess-1");
346
+ assert.deepEqual(res, { disposed: 1 });
347
+ assert.ok(svc.getById(s1.memory.id).session_disposed_at, "session-1 memory marked disposed");
348
+ // disposed memories vanish from default list (like archived)
349
+ assert.ok(!svc.list({ type: "project" }).some((m) => m.id === s1.memory.id), "disposed hidden from default list");
350
+ const other = svc.list({ type: "project", includeDisposed: true });
351
+ assert.ok(other.some((m) => m.title === "会话B记忆" && !m.session_disposed_at), "other session untouched");
352
+ });
353
+
354
+ test("restoreBySession clears the dispose mark and makes entries visible again", () => {
355
+ const store = createStore(":memory:");
356
+ const svc = createService({ store, mirror: null, config: {} });
357
+ const s1 = svc.saveWithDedupe({ type: "project", title: "存档会话", content: "x", session_id: "sess-9" });
358
+ svc.disposeBySession("sess-9");
359
+
360
+ assert.ok(!svc.list({ type: "project" }).some((m) => m.id === s1.memory.id), "hidden while disposed");
361
+ const res = svc.restoreBySession("sess-9");
362
+ assert.deepEqual(res, { restored: 1 });
363
+ assert.equal(svc.getById(s1.memory.id).session_disposed_at, undefined, "dispose mark cleared");
364
+ assert.ok(svc.list({ type: "project" }).some((m) => m.id === s1.memory.id), "visible again");
365
+ });
366
+
367
+ test("disposeBySession is idempotent: re-disposing returns 0", () => {
368
+ const store = createStore(":memory:");
369
+ const svc = createService({ store, mirror: null, config: {} });
370
+ svc.saveWithDedupe({ type: "project", title: "重复销毁", content: "x", session_id: "sess-3" });
371
+
372
+ assert.deepEqual(svc.disposeBySession("sess-3"), { disposed: 1 });
373
+ assert.deepEqual(svc.disposeBySession("sess-3"), { disposed: 0 }, "no rows flipped the second time");
374
+ });
375
+
376
+ test("disposeBySession on an unknown session is a no-op", () => {
377
+ const store = createStore(":memory:");
378
+ const svc = createService({ store, mirror: null, config: {} });
379
+ assert.deepEqual(svc.disposeBySession("nope"), { disposed: 0 });
380
+ assert.deepEqual(svc.restoreBySession("nope"), { restored: 0 });
381
+ });
382
+
383
+ test("legacy rows without session_id are never touched by session lifecycle", () => {
384
+ const store = createStore(":memory:");
385
+ const svc = createService({ store, mirror: null, config: {} });
386
+ const legacy = svc.saveWithDedupe({ type: "preference", title: "全局记忆", content: "no session", session_id: undefined });
387
+
388
+ svc.disposeBySession("sess-x");
389
+ assert.equal(svc.getById(legacy.memory.id).session_disposed_at, undefined, "global memory survives session dispose");
390
+ });
391
+
392
+ test("restoreBySession does NOT resurrect user-archived memories (orthogonal)", () => {
393
+ const store = createStore(":memory:");
394
+ const svc = createService({ store, mirror: null, config: {} });
395
+ // user manually archived this one (archived=true), then the session is disposed
396
+ const { memory: userArchived } = svc.saveWithDedupe({ type: "decision", title: "手动归档", content: "x", session_id: "sess-4" });
397
+ svc.setArchived(userArchived.id, true);
398
+ svc.disposeBySession("sess-4");
399
+
400
+ svc.restoreBySession("sess-4");
401
+ assert.equal(svc.getById(userArchived.id).archived, true, "user archive survives restore");
402
+ assert.equal(svc.getById(userArchived.id).session_disposed_at, undefined, "dispose mark cleared but archive kept");
403
+ assert.ok(!svc.list({ type: "decision" }).some((m) => m.id === userArchived.id), "still hidden by user archive");
404
+ });
405
+
406
+ test("listBySession returns only that session's memories via wire DTOs", () => {
407
+ const store = createStore(":memory:");
408
+ const svc = createService({ store, mirror: null, config: {} });
409
+ const { memory } = svc.saveWithDedupe({ type: "decision", title: "会话决策", content: "d", session_id: "sess-7" });
410
+ svc.saveWithDedupe({ type: "decision", title: "别会话", content: "e", session_id: "sess-8" });
411
+
412
+ const rows = svc.listBySession("sess-7");
413
+ assert.equal(rows.length, 1);
414
+ assert.equal(rows[0].id, memory.id);
415
+ assert.equal(rows[0].title, "会话决策");
416
+ assert.equal(rows[0].session_id, "sess-7");
417
+ });
418
+
419
+ test("listBySession DTO hides disposed by default and flags it when included", () => {
420
+ const store = createStore(":memory:");
421
+ const svc = createService({ store, mirror: null, config: {} });
422
+ const { memory } = svc.saveWithDedupe({ type: "decision", title: "会话决策", content: "d", session_id: "sess-9" });
423
+ svc.disposeBySession("sess-9");
424
+
425
+ assert.equal(svc.listBySession("sess-9").length, 0, "disposed hidden by default");
426
+ const visible = svc.listBySession("sess-9", { includeDisposed: true });
427
+ assert.equal(visible.length, 1);
428
+ assert.equal(visible[0].id, memory.id);
429
+ assert.equal(visible[0].disposed, true, "DTO carries disposed marker so restore is not blind");
430
+
431
+ svc.restoreBySession("sess-9");
432
+ const restored = svc.listBySession("sess-9");
433
+ assert.equal(restored.length, 1, "restore brings it back to default view");
434
+ assert.equal(restored[0].disposed, undefined, "marker cleared after restore");
435
+ });
@@ -358,3 +358,79 @@ test("conflict_pending table persists across store reopen", () => {
358
358
  assert.equal(pending[0].reason, "x");
359
359
  s2.close();
360
360
  });
361
+
362
+ // --- session lifecycle (v0.6.0): disposed rows are filtered out ------------
363
+
364
+ test("session_disposed_at column exists and survives migration", () => {
365
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-disposed-"));
366
+ const path = join(dir, "memory.db");
367
+ const s1 = createStore(path);
368
+ const { id } = s1.save({ type: "decision", title: "迁移", content: "c", session_id: "sess-m" });
369
+ s1.setDisposedBySession("sess-m", true);
370
+ const row = s1.db.prepare("SELECT session_disposed_at FROM memories WHERE id = ?").get(id);
371
+ assert.ok(row.session_disposed_at, "dispose timestamp persisted");
372
+ s1.close();
373
+ // reopen: column survives
374
+ const s2 = createStore(path);
375
+ const live = s2.list({ type: "decision" });
376
+ assert.ok(!live.some((m) => m.id === id), "disposed row excluded after reopen");
377
+ s2.close();
378
+ });
379
+
380
+ test("search and list exclude disposed rows unless includeDisposed", () => {
381
+ const store = createStore(":memory:");
382
+ const a = store.save({ type: "decision", title: "活着", content: "keyword alpha", session_id: "s1" });
383
+ const b = store.save({ type: "decision", title: "被销毁", content: "keyword beta", session_id: "s2" });
384
+ store.setDisposedBySession("s2", true);
385
+
386
+ const searchRes = store.search("keyword");
387
+ assert.ok(searchRes.some((m) => m.id === a.id), "live row found");
388
+ assert.ok(!searchRes.some((m) => m.id === b.id), "disposed row excluded from search");
389
+ const searchIncl = store.search("keyword", { includeDisposed: true });
390
+ assert.ok(searchIncl.some((m) => m.id === b.id), "includeDisposed surfaces it");
391
+
392
+ const listRes = store.list({ type: "decision" });
393
+ assert.ok(listRes.some((m) => m.id === a.id));
394
+ assert.ok(!listRes.some((m) => m.id === b.id), "disposed excluded from list");
395
+ const listIncl = store.list({ type: "decision", includeDisposed: true });
396
+ assert.ok(listIncl.some((m) => m.id === b.id), "includeDisposed lists it");
397
+ assert.equal(store.count("decision", { includeDisposed: true }), 2);
398
+ });
399
+
400
+ test("searchVector excludes disposed rows", () => {
401
+ const store = createStore(":memory:");
402
+ const a = store.save({ type: "decision", title: "向量A", content: "v", session_id: "s1" });
403
+ const b = store.save({ type: "decision", title: "向量B", content: "v", session_id: "s2" });
404
+ store.setEmbedding(a.id, [1, 0, 0]);
405
+ store.setEmbedding(b.id, [1, 0, 0]);
406
+ store.setDisposedBySession("s2", true);
407
+
408
+ const res = store.searchVector([1, 0, 0], { limit: 10 });
409
+ assert.ok(res.some((m) => m.id === a.id));
410
+ assert.ok(!res.some((m) => m.id === b.id), "disposed row excluded from vector search");
411
+ });
412
+
413
+ test("setDisposedBySession round-trips and listBySession sees all states", () => {
414
+ const store = createStore(":memory:");
415
+ const a = store.save({ type: "decision", title: "会话记忆", content: "c", session_id: "sess-z" });
416
+ assert.equal(store.setDisposedBySession("sess-z", true), 1, "first dispose flips 1");
417
+ assert.equal(store.setDisposedBySession("sess-z", true), 0, "re-dispose is a no-op");
418
+ assert.equal(store.setDisposedBySession("sess-z", false), 1, "restore flips 1");
419
+ assert.equal(store.setDisposedBySession("sess-z", false), 0, "re-restore is a no-op");
420
+ assert.equal(store.getById(a.id).session_disposed_at, undefined, "mark cleared after restore");
421
+
422
+ const bySession = store.listBySession("sess-z");
423
+ assert.equal(bySession.length, 1);
424
+ assert.equal(bySession[0].id, a.id);
425
+ });
426
+
427
+ test("listBySession hides disposed by default, includeDisposed opts in", () => {
428
+ const store = createStore(":memory:");
429
+ const a = store.save({ type: "decision", title: "会话记忆", content: "c", session_id: "sess-y" });
430
+ store.setDisposedBySession("sess-y", true);
431
+
432
+ assert.equal(store.listBySession("sess-y").length, 0, "disposed row hidden by default");
433
+ const all = store.listBySession("sess-y", { includeDisposed: true });
434
+ assert.equal(all.length, 1, "includeDisposed reveals it");
435
+ assert.equal(all[0].id, a.id);
436
+ });
@@ -142,6 +142,26 @@ test("memory_delete on missing id returns deleted:false", async () => {
142
142
  assert.equal(result.deleted, false);
143
143
  });
144
144
 
145
+ test("memory_delete by query deletes the best match (no id round-trip)", async () => {
146
+ const { registered, service, store } = setup();
147
+ const { memory } = service.saveWithDedupe({ type: "preference", title: "喜欢 Rust", content: "用户偏好 Rust 优先于 Go", importance: 4 });
148
+ service.saveWithDedupe({ type: "preference", title: "喜欢 Python", content: "用户偏好 Python 写脚本", importance: 3 });
149
+ const del = registered.find((t) => t.name === "memory_delete");
150
+ const result = await del.execute({ query: "Rust" });
151
+ assert.equal(result.deleted, true);
152
+ assert.equal(service.getById(memory.id), undefined, "best-matching Rust entry removed");
153
+ assert.equal(store.count(), 1, "non-matching entry untouched");
154
+ });
155
+
156
+ test("memory_delete by query with no match returns deleted:false", async () => {
157
+ const { registered, service, store } = setup();
158
+ service.saveWithDedupe({ type: "preference", title: "喜欢 Rust", content: "用户偏好 Rust", importance: 4 });
159
+ const del = registered.find((t) => t.name === "memory_delete");
160
+ const result = await del.execute({ query: "完全不存在的关键词xyz" });
161
+ assert.equal(result.deleted, false);
162
+ assert.equal(store.count(), 1, "nothing removed when no match");
163
+ });
164
+
145
165
  test("memory_forget suppresses injection without deleting", async () => {
146
166
  const { registered, service, store } = setup();
147
167
  const { memory } = service.saveWithDedupe({ type: "project", title: "t", content: "c", importance: 5 });
@@ -0,0 +1,332 @@
1
+ // v0.6.1 Wiki-Link(显式跨记忆链接)测试。
2
+ // 覆盖:解析器 parseWikiLinks(正常/显示文本/未闭合/空/多链接/非法)/
3
+ // resolveWikiLink 大小写匹配 / 存储幂等(同对只写一条、目标不存在跳过)/
4
+ // links_to 的 partial 唯一索引(仅 links_to 去重,其余 relation 保持 append-only)/
5
+ // saveWikiLinks 幂等 / service 三 API(getBacklinks/getForwardLinks/
6
+ // resolveWikiLink)与 saveWithDedupe 的 wikiLinkEnabled hook / 三个只读 HTTP 端点。
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { EventEmitter } from "node:events";
10
+ import { createStore } from "../src/store.js";
11
+ import { createService } from "../src/service.js";
12
+ import { createApi } from "../src/api.js";
13
+ import { createSettings } from "../src/settings.js";
14
+ import { parseWikiLinks } from "../src/parser/wiki-link.js";
15
+
16
+ function openStore() {
17
+ return createStore(":memory:");
18
+ }
19
+
20
+ function makeService(config = {}) {
21
+ const store = createStore(":memory:");
22
+ const service = createService({ store, mirror: null, config });
23
+ return { store, service };
24
+ }
25
+
26
+ /** Let fire-and-forget enqueue tasks (wiki-link resolve) settle. */
27
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
28
+
29
+ function seed(service, title, content, type = "preference") {
30
+ return service.saveWithDedupe({ type, title, content, importance: 3 }).memory;
31
+ }
32
+
33
+ // ============================================================ parser
34
+
35
+ test("parseWikiLinks: plain [[target]] returns display=target", () => {
36
+ assert.deepEqual(parseWikiLinks("看看 [[Alpha]] 这篇"), [
37
+ { display: "Alpha", target: "Alpha" }
38
+ ]);
39
+ });
40
+
41
+ test("parseWikiLinks: [[显示|target]] keeps display text separate from target", () => {
42
+ assert.deepEqual(parseWikiLinks("参考 [[精彩解读|Alpha]] 一文"), [
43
+ { display: "精彩解读", target: "Alpha" }
44
+ ]);
45
+ });
46
+
47
+ test("parseWikiLinks: unclosed [[ marker is ignored", () => {
48
+ assert.deepEqual(parseWikiLinks("这里有个没闭合的 [[Alpha"), []);
49
+ assert.deepEqual(parseWikiLinks("[[Alpha] 只有一个括号"), []);
50
+ });
51
+
52
+ test("parseWikiLinks: empty targets are ignored", () => {
53
+ assert.deepEqual(parseWikiLinks("[[]]"), []);
54
+ assert.deepEqual(parseWikiLinks("[[ ]]"), []);
55
+ assert.deepEqual(parseWikiLinks("[[显示|]]"), []);
56
+ assert.deepEqual(parseWikiLinks("[[|]]"), []);
57
+ });
58
+
59
+ test("parseWikiLinks: multiple links are all returned in order", () => {
60
+ const links = parseWikiLinks("[[A]] 与 [[B]] 以及 [[C|D]]");
61
+ assert.deepEqual(links, [
62
+ { display: "A", target: "A" },
63
+ { display: "B", target: "B" },
64
+ { display: "C", target: "D" }
65
+ ]);
66
+ });
67
+
68
+ test("parseWikiLinks: multi-pipe and bracket-nested markers are illegal", () => {
69
+ assert.deepEqual(parseWikiLinks("[[a|b|c]]"), [], "multi-pipe skipped");
70
+ assert.deepEqual(parseWikiLinks("[[a [[b]] c]]"), [
71
+ { display: "b", target: "b" }
72
+ ], "only the well-formed inner [[b]] is kept");
73
+ });
74
+
75
+ test("parseWikiLinks: non-string / empty content returns []", () => {
76
+ assert.deepEqual(parseWikiLinks(""), []);
77
+ assert.deepEqual(parseWikiLinks(undefined), []);
78
+ assert.deepEqual(parseWikiLinks(null), []);
79
+ assert.deepEqual(parseWikiLinks("没有链接的普通文本"), []);
80
+ });
81
+
82
+ test("parseWikiLinks: display falls back to target when the display side is empty", () => {
83
+ assert.deepEqual(parseWikiLinks("[[|Beta]]"), [
84
+ { display: "Beta", target: "Beta" }
85
+ ]);
86
+ });
87
+
88
+ // ============================================================ storage
89
+
90
+ test("partial unique index on links_to (from_entity,to_entity) is created", () => {
91
+ const store = openStore();
92
+ const idx = store.db.prepare(
93
+ "SELECT name, sql FROM sqlite_master WHERE type='index' AND name='idx_relations_wikilink'"
94
+ ).get();
95
+ assert.ok(idx, "idx_relations_wikilink exists");
96
+ assert.match(idx.sql, /WHERE relation_type = 'links_to'/i,
97
+ "index is partial — scoped to links_to only");
98
+ const fullUnique = store.db.prepare(
99
+ "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_relations_unique'"
100
+ ).get();
101
+ assert.ok(!fullUnique, "no full-table unique index (keeps other types append-only)");
102
+ store.close();
103
+ });
104
+
105
+ test("saveWikiLinks: same target pair writes exactly one row (idempotent)", () => {
106
+ const store = openStore();
107
+ const a = store.save({ type: "preference", title: "Alpha", content: "c", tags: [] });
108
+ store.save({ type: "project", title: "Beta", content: "c", tags: [] });
109
+ const r1 = store.saveWikiLinks({ memoryId: a.id, title: a.title, targets: ["Beta"] });
110
+ const r2 = store.saveWikiLinks({ memoryId: a.id, title: a.title, targets: ["Beta"] });
111
+ assert.equal(r1.saved.length, 1);
112
+ assert.equal(r2.saved.length, 0, "second call is a no-op");
113
+ assert.deepEqual(r2.skipped, []);
114
+ const rels = store.getRelations(a.title).filter((r) => r.relation_type === "links_to");
115
+ assert.equal(rels.length, 1, "one links_to row total");
116
+ assert.equal(rels[0].from_entity, "Alpha");
117
+ assert.equal(rels[0].to_entity, "Beta");
118
+ assert.equal(rels[0].memory_id, a.id);
119
+ store.close();
120
+ });
121
+
122
+ test("saveWikiLinks: missing target is skipped, not an error (fail-safe)", () => {
123
+ const store = openStore();
124
+ const a = store.save({ type: "preference", title: "Alpha", content: "c", tags: [] });
125
+ store.save({ type: "project", title: "Beta", content: "c", tags: [] });
126
+ const r = store.saveWikiLinks({
127
+ memoryId: a.id,
128
+ title: a.title,
129
+ targets: ["不存在", "Beta"]
130
+ });
131
+ assert.equal(r.saved.length, 1);
132
+ assert.deepEqual(r.skipped, ["不存在"]);
133
+ store.close();
134
+ });
135
+
136
+ test("saveWikiLinks: target title matches case-insensitively", () => {
137
+ const store = openStore();
138
+ const a = store.save({ type: "preference", title: "Alpha", content: "c", tags: [] });
139
+ const b = store.save({ type: "project", title: "Beta", content: "c", tags: [] });
140
+ const r = store.saveWikiLinks({ memoryId: a.id, title: a.title, targets: ["BETA"] });
141
+ assert.equal(r.saved.length, 1, "lowercase target resolves the mixed-case title");
142
+ assert.equal(r.skipped.length, 0);
143
+ const rel = store.getRelations(a.title).find((x) => x.relation_type === "links_to");
144
+ assert.ok(rel, "relation written");
145
+ // resolved target memory carried in metadata
146
+ assert.equal(rel.metadata?.target_memory_id, b.id);
147
+ store.close();
148
+ });
149
+
150
+ test("saveRelation stays append-only for non-links_to (preserves extractor/dream semantics)", () => {
151
+ const store = openStore();
152
+ store.saveRelation({ from_entity: "X", to_entity: "Y", relation_type: "uses" });
153
+ store.saveRelation({ from_entity: "X", to_entity: "Y", relation_type: "uses" });
154
+ // two rows — not deduped. (saveRelation's return is a SELECT LIMIT 1, so it
155
+ // always yields the first matching row; the row count is the real signal.)
156
+ assert.equal(store.getRelations("X").filter((r) => r.relation_type === "uses").length, 2);
157
+ store.close();
158
+ });
159
+
160
+ test("partial unique index dedups links_to at the DB level", () => {
161
+ const store = openStore();
162
+ store.saveRelation({ from_entity: "A", to_entity: "B", relation_type: "links_to" });
163
+ assert.throws(
164
+ () => store.saveRelation({ from_entity: "A", to_entity: "B", relation_type: "links_to" }),
165
+ /UNIQUE constraint failed/,
166
+ "second links_to for the same pair is rejected by idx_relations_wikilink"
167
+ );
168
+ store.close();
169
+ });
170
+
171
+ // ============================================================ service read APIs
172
+
173
+ test("service.getForwardLinks returns target memories the memory links to", () => {
174
+ const { store, service } = makeService({ wikiLinkEnabled: true });
175
+ const a = seed(service, "Alpha", "参见 [[Beta]]", "preference");
176
+ const b = seed(service, "Beta", "正文", "project");
177
+ const rel = store.saveWikiLinks({ memoryId: a.id, title: a.title, targets: ["Beta"] });
178
+ assert.equal(rel.saved.length, 1);
179
+ const links = service.getForwardLinks(a.id);
180
+ assert.equal(links.length, 1);
181
+ assert.equal(links[0].target.id, b.id);
182
+ assert.equal(links[0].target.title, "Beta");
183
+ assert.equal(links[0].relation.relation_type, "links_to");
184
+ store.close();
185
+ });
186
+
187
+ test("service.getBacklinks returns source memories that link to a memory", () => {
188
+ const { store, service } = makeService({ wikiLinkEnabled: true });
189
+ const a = seed(service, "Alpha", "参见 [[Beta]]", "preference");
190
+ const b = seed(service, "Beta", "正文", "project");
191
+ store.saveWikiLinks({ memoryId: a.id, title: a.title, targets: ["beta"] }); // case-insensitive resolve
192
+ const backlinks = service.getBacklinks(b.id);
193
+ assert.equal(backlinks.length, 1);
194
+ assert.equal(backlinks[0].source.id, a.id);
195
+ assert.equal(backlinks[0].source.title, "Alpha");
196
+ store.close();
197
+ });
198
+
199
+ test("service.resolveWikiLink matches titles case-insensitively", () => {
200
+ const { store, service } = makeService();
201
+ store.save({ type: "project", title: "Beta", content: "c", tags: [] });
202
+ const mem = service.resolveWikiLink("BETA");
203
+ assert.ok(mem, "resolved");
204
+ assert.equal(mem.title, "Beta");
205
+ assert.equal(service.resolveWikiLink("不存在"), undefined);
206
+ store.close();
207
+ });
208
+
209
+ // ============================================================ service hook
210
+
211
+ test("saveWithDedupe resolves [[wiki-links]] when wikiLinkEnabled=true", async () => {
212
+ const { store, service } = makeService({ wikiLinkEnabled: true });
213
+ seed(service, "Beta", "正文", "project");
214
+ seed(service, "Alpha", "参见 [[Beta]] 和 [[不存在]]", "preference");
215
+ await flush(); // fire-and-forget enqueue task settles
216
+ const rels = store.getRelations("Alpha").filter((r) => r.relation_type === "links_to");
217
+ assert.equal(rels.length, 1, "Beta resolved; 不存在 skipped");
218
+ assert.equal(rels[0].to_entity, "Beta");
219
+ store.close();
220
+ });
221
+
222
+ test("saveWithDedupe writes no links_to when wikiLinkEnabled=false (default)", async () => {
223
+ const { store, service } = makeService();
224
+ seed(service, "Beta", "正文", "project");
225
+ seed(service, "Alpha", "参见 [[Beta]]", "preference");
226
+ await flush();
227
+ assert.equal(store.getRelations("Alpha").filter((r) => r.relation_type === "links_to").length, 0);
228
+ store.close();
229
+ });
230
+
231
+ test("service.update re-resolves [[wiki-links]] for edited content", async () => {
232
+ const { store, service } = makeService({ wikiLinkEnabled: true });
233
+ seed(service, "Beta", "正文", "project");
234
+ const a = seed(service, "Alpha", "暂无链接", "preference");
235
+ service.update(a.id, { content: "现在指向 [[Beta]]" });
236
+ await flush();
237
+ const rels = store.getRelations("Alpha").filter((r) => r.relation_type === "links_to");
238
+ assert.equal(rels.length, 1);
239
+ assert.equal(rels[0].to_entity, "Beta");
240
+ store.close();
241
+ });
242
+
243
+ // ============================================================ HTTP endpoints
244
+
245
+ class FakeRes extends EventEmitter {
246
+ constructor() { super(); this.statusCode = 200; this.body = ""; }
247
+ writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
248
+ end(text) { this.body = text ?? ""; this.emit("end"); return this; }
249
+ }
250
+
251
+ function req(path) {
252
+ const r = new EventEmitter();
253
+ r.url = path;
254
+ r.method = "GET";
255
+ r.headers = {};
256
+ return r;
257
+ }
258
+
259
+ function setup(apiToken = "") {
260
+ const store = createStore(":memory:");
261
+ const service = createService({ store, mirror: null, config: {} });
262
+ const settings = createSettings(store.db);
263
+ const commands = { add: () => {}, remove: () => {}, list: () => [] };
264
+ const routes = [];
265
+ const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
266
+ createApi(ctx, service, settings, commands, null, undefined, apiToken);
267
+ return { store, service, routes };
268
+ }
269
+
270
+ function wikiRoute(routes, path) {
271
+ return routes.find((r) => r.path === path);
272
+ }
273
+
274
+ test("three wiki-link endpoints are registered and open without apiToken", async () => {
275
+ const { routes } = setup("secret-token");
276
+ for (const p of [
277
+ "/api/dsh-mneme/wikilinks/backlinks",
278
+ "/api/dsh-mneme/wikilinks/forward",
279
+ "/api/dsh-mneme/wikilinks/resolve"
280
+ ]) {
281
+ assert.ok(wikiRoute(routes, p), `${p} registered`);
282
+ }
283
+ // read-only: missing param is a 400, never a 401
284
+ const back = wikiRoute(routes, "/api/dsh-mneme/wikilinks/backlinks");
285
+ const res = new FakeRes();
286
+ await back.handler(req("/api/dsh-mneme/wikilinks/backlinks"), res);
287
+ assert.equal(res.statusCode, 400);
288
+ });
289
+
290
+ test("GET backlinks returns linking source memories", async () => {
291
+ const { routes, store, service } = setup();
292
+ const a = seed(service, "Alpha", "参见 [[Beta]]", "preference");
293
+ const b = seed(service, "Beta", "正文", "project");
294
+ store.saveWikiLinks({ memoryId: a.id, title: a.title, targets: ["Beta"] });
295
+ const route = wikiRoute(routes, "/api/dsh-mneme/wikilinks/backlinks");
296
+ const res = new FakeRes();
297
+ await route.handler(req(`/api/dsh-mneme/wikilinks/backlinks?id=${b.id}`), res);
298
+ assert.equal(res.statusCode, 200);
299
+ const data = JSON.parse(res.body);
300
+ assert.equal(data.memoryId, b.id);
301
+ assert.equal(data.backlinks.length, 1);
302
+ assert.equal(data.backlinks[0].title, "Alpha");
303
+ assert.equal(data.backlinks[0].id, a.id);
304
+ });
305
+
306
+ test("GET forward links returns target memories", async () => {
307
+ const { routes, store, service } = setup();
308
+ const a = seed(service, "Alpha", "参见 [[Beta]]", "preference");
309
+ const b = seed(service, "Beta", "正文", "project");
310
+ store.saveWikiLinks({ memoryId: a.id, title: a.title, targets: ["Beta"] });
311
+ const route = wikiRoute(routes, "/api/dsh-mneme/wikilinks/forward");
312
+ const res = new FakeRes();
313
+ await route.handler(req(`/api/dsh-mneme/wikilinks/forward?id=${a.id}`), res);
314
+ assert.equal(res.statusCode, 200);
315
+ const data = JSON.parse(res.body);
316
+ assert.equal(data.links.length, 1);
317
+ assert.equal(data.links[0].title, "Beta");
318
+ assert.equal(data.links[0].id, b.id);
319
+ });
320
+
321
+ test("GET resolve matches title case-insensitively and 404s when absent", async () => {
322
+ const { routes, store, service } = setup();
323
+ store.save({ type: "project", title: "Beta", content: "c", tags: [] });
324
+ const route = wikiRoute(routes, "/api/dsh-mneme/wikilinks/resolve");
325
+ const ok = new FakeRes();
326
+ await route.handler(req("/api/dsh-mneme/wikilinks/resolve?title=BETA"), ok);
327
+ assert.equal(ok.statusCode, 200);
328
+ assert.equal(JSON.parse(ok.body).memory.title, "Beta");
329
+ const missing = new FakeRes();
330
+ await route.handler(req("/api/dsh-mneme/wikilinks/resolve?title=nope"), missing);
331
+ assert.equal(missing.statusCode, 404);
332
+ });