@modusensus/dsh-mneme 0.6.7 → 0.6.9

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.
Files changed (139) hide show
  1. package/.github/workflows/test.yml +32 -0
  2. package/.release-notes-v0.6.9.md +13 -0
  3. package/CHANGELOG.md +89 -0
  4. package/LICENSE +21 -21
  5. package/README.md +219 -463
  6. package/SECURITY.md +544 -0
  7. package/docs/devlog/2026-08-14-dsh-mneme-dev-log.md +247 -0
  8. package/docs/devlog/2026-08-15-dsh-mneme-audit-stress-dev-log.md +145 -0
  9. package/docs/devlog/2026-08-15-dsh-mneme-pipeline-dev-log.md +56 -0
  10. package/docs/devlog/2026-08-15-dsh-mneme-reflection-dev-log.md +77 -0
  11. package/docs/devlog/2026-08-15-dsh-mneme-review-fixes-dev-log.md +64 -0
  12. package/docs/devlog/2026-08-15-dsh-mneme-semantic-dev-log.md +90 -0
  13. package/dsh-mneme/CHANGELOG.md +248 -0
  14. package/dsh-mneme/LICENSE +21 -0
  15. package/dsh-mneme/README.md +465 -0
  16. package/{cordis.patch.yml → dsh-mneme/cordis.patch.yml} +15 -15
  17. package/dsh-mneme/docs/AGENT_MEMORY_RESEARCH.md +183 -0
  18. package/dsh-mneme/docs/ENTITIES.md +245 -0
  19. package/dsh-mneme/docs/LOCAL_MODEL.md +141 -0
  20. package/dsh-mneme/docs/MIGRATION.md +127 -0
  21. package/dsh-mneme/docs/SEMANTIC.md +256 -0
  22. package/dsh-mneme/docs/SLEEP.md +163 -0
  23. package/{lib → dsh-mneme/lib}/api.js +783 -783
  24. package/{lib → dsh-mneme/lib}/client.js +1757 -1757
  25. package/{src → dsh-mneme/lib}/commands.js +64 -64
  26. package/{lib → dsh-mneme/lib}/config.js +298 -288
  27. package/{lib → dsh-mneme/lib}/dream/clustering.js +118 -118
  28. package/{src → dsh-mneme/lib}/dream/decisions.js +488 -439
  29. package/{lib → dsh-mneme/lib}/dream/sleep.js +561 -554
  30. package/{src → dsh-mneme/lib}/dream/tag-extractor.js +156 -156
  31. package/{lib → dsh-mneme/lib}/dream.js +958 -929
  32. package/{src → dsh-mneme/lib}/embedding.js +154 -154
  33. package/{src → dsh-mneme/lib}/entities/extractor.js +242 -242
  34. package/{src → dsh-mneme/lib}/hot-memory.js +53 -53
  35. package/{lib → dsh-mneme/lib}/index.js +361 -361
  36. package/{src → dsh-mneme/lib}/inject.js +208 -208
  37. package/{lib → dsh-mneme/lib}/local-embedder.js +282 -282
  38. package/{lib → dsh-mneme/lib}/mirror.js +170 -170
  39. package/{lib → dsh-mneme/lib}/parser/tag.js +59 -59
  40. package/{lib → dsh-mneme/lib}/parser/wiki-link.js +38 -38
  41. package/{src → dsh-mneme/lib}/quality-filter.js +123 -123
  42. package/{lib → dsh-mneme/lib}/reranker.js +218 -218
  43. package/{lib → dsh-mneme/lib}/search/adaptive.js +22 -22
  44. package/{lib → dsh-mneme/lib}/search/bm25.js +96 -96
  45. package/{src → dsh-mneme/lib}/search/tag-boost.js +61 -61
  46. package/{src → dsh-mneme/lib}/service.js +1726 -1726
  47. package/{lib → dsh-mneme/lib}/settings.js +172 -172
  48. package/{lib → dsh-mneme/lib}/store.js +2238 -2238
  49. package/{lib → dsh-mneme/lib}/summarize.js +236 -236
  50. package/{lib → dsh-mneme/lib}/tools.js +290 -290
  51. package/{lib → dsh-mneme/lib}/vector-index.js +116 -116
  52. package/dsh-mneme/package-lock.json +1936 -0
  53. package/dsh-mneme/package.json +80 -0
  54. package/{scripts → dsh-mneme/scripts}/benchmark-embed.js +201 -201
  55. package/{scripts → dsh-mneme/scripts}/benchmark-recall.js +133 -133
  56. package/{scripts → dsh-mneme/scripts}/benchmark-rerank.js +166 -166
  57. package/{scripts → dsh-mneme/scripts}/e2e-dsh.js +218 -218
  58. package/{scripts → dsh-mneme/scripts}/stress-dsh.js +255 -255
  59. package/{scripts → dsh-mneme/scripts}/sync-lib.js +52 -52
  60. package/{src → dsh-mneme/src}/api.js +783 -783
  61. package/{lib → dsh-mneme/src}/commands.js +64 -64
  62. package/{src → dsh-mneme/src}/config.js +298 -288
  63. package/{src → dsh-mneme/src}/dream/clustering.js +118 -118
  64. package/{lib → dsh-mneme/src}/dream/decisions.js +488 -439
  65. package/{src → dsh-mneme/src}/dream/sleep.js +561 -554
  66. package/{lib → dsh-mneme/src}/dream/tag-extractor.js +156 -156
  67. package/{src → dsh-mneme/src}/dream.js +958 -929
  68. package/{lib → dsh-mneme/src}/embedding.js +154 -154
  69. package/{lib → dsh-mneme/src}/entities/extractor.js +242 -242
  70. package/{lib → dsh-mneme/src}/hot-memory.js +53 -53
  71. package/{src → dsh-mneme/src}/index.js +361 -361
  72. package/{lib → dsh-mneme/src}/inject.js +208 -208
  73. package/{src → dsh-mneme/src}/local-embedder.js +282 -282
  74. package/{src → dsh-mneme/src}/mirror.js +170 -170
  75. package/{src → dsh-mneme/src}/parser/tag.js +59 -59
  76. package/{src → dsh-mneme/src}/parser/wiki-link.js +38 -38
  77. package/{lib → dsh-mneme/src}/quality-filter.js +123 -123
  78. package/{src → dsh-mneme/src}/reranker.js +218 -218
  79. package/{src → dsh-mneme/src}/search/adaptive.js +22 -22
  80. package/{src → dsh-mneme/src}/search/bm25.js +96 -96
  81. package/{lib → dsh-mneme/src}/search/tag-boost.js +61 -61
  82. package/{lib → dsh-mneme/src}/service.js +1726 -1726
  83. package/{src → dsh-mneme/src}/settings.js +172 -172
  84. package/{src → dsh-mneme/src}/store.js +2238 -2238
  85. package/{src → dsh-mneme/src}/summarize.js +236 -236
  86. package/{src → dsh-mneme/src}/tools.js +290 -290
  87. package/{src → dsh-mneme/src}/vector-index.js +116 -116
  88. package/{test → dsh-mneme/test}/api.test.js +594 -594
  89. package/{test → dsh-mneme/test}/audit.test.js +448 -448
  90. package/{test → dsh-mneme/test}/benchmark.test.js +35 -35
  91. package/{test → dsh-mneme/test}/boundary-v0625.test.js +82 -82
  92. package/{test → dsh-mneme/test}/client.test.js +368 -368
  93. package/{test → dsh-mneme/test}/clustering.test.js +100 -100
  94. package/{test → dsh-mneme/test}/commands.test.js +69 -69
  95. package/{test → dsh-mneme/test}/config.test.js +50 -50
  96. package/{test → dsh-mneme/test}/conflict-freeze.test.js +290 -290
  97. package/{test → dsh-mneme/test}/directory.test.js +134 -134
  98. package/{test → dsh-mneme/test}/dream.test.js +1060 -901
  99. package/{test → dsh-mneme/test}/entities.test.js +522 -522
  100. package/{test → dsh-mneme/test}/epistemic.test.js +298 -298
  101. package/{test → dsh-mneme/test}/fnew-0112.test.js +311 -311
  102. package/{test → dsh-mneme/test}/fnew-03.test.js +422 -422
  103. package/{test → dsh-mneme/test}/graph-api.test.js +175 -175
  104. package/{test → dsh-mneme/test}/helpers/dream-mock.js +82 -82
  105. package/{test → dsh-mneme/test}/hot-memory.test.js +174 -174
  106. package/{test → dsh-mneme/test}/inject.test.js +103 -103
  107. package/{test → dsh-mneme/test}/llm-audit.test.js +279 -279
  108. package/{test → dsh-mneme/test}/local-embedder.test.js +227 -227
  109. package/{test → dsh-mneme/test}/mirror-dirty.test.js +424 -424
  110. package/{test → dsh-mneme/test}/mirror-edit-digest.test.js +187 -187
  111. package/{test → dsh-mneme/test}/mirror-generation.test.js +499 -499
  112. package/{test → dsh-mneme/test}/mirror.test.js +249 -249
  113. package/{test → dsh-mneme/test}/normalize-decisions.test.js +120 -120
  114. package/{test → dsh-mneme/test}/peer-blockers.test.js +190 -190
  115. package/{test → dsh-mneme/test}/policy-epoch.test.js +259 -259
  116. package/{test → dsh-mneme/test}/provenance.test.js +103 -103
  117. package/{test → dsh-mneme/test}/quality-filter.test.js +118 -118
  118. package/{test → dsh-mneme/test}/reasoning-effort.test.js +199 -199
  119. package/{test → dsh-mneme/test}/recall-evals.test.js +235 -235
  120. package/{test → dsh-mneme/test}/recall-layer.test.js +315 -315
  121. package/{test → dsh-mneme/test}/receipt-chain.test.js +451 -451
  122. package/{test → dsh-mneme/test}/reflection.test.js +226 -226
  123. package/{test → dsh-mneme/test}/reranker.test.js +240 -240
  124. package/{test → dsh-mneme/test}/search-fusion.test.js +90 -90
  125. package/{test → dsh-mneme/test}/semantic.test.js +124 -124
  126. package/{test → dsh-mneme/test}/service-search.test.js +199 -199
  127. package/{test → dsh-mneme/test}/service.test.js +435 -435
  128. package/{test → dsh-mneme/test}/settings.test.js +118 -118
  129. package/{test → dsh-mneme/test}/sleep.test.js +365 -365
  130. package/{test → dsh-mneme/test}/store.test.js +436 -436
  131. package/{test → dsh-mneme/test}/stress.test.js +209 -209
  132. package/{test → dsh-mneme/test}/summarize.test.js +191 -191
  133. package/{test → dsh-mneme/test}/tag-boost.test.js +125 -125
  134. package/{test → dsh-mneme/test}/tag.test.js +312 -312
  135. package/{test → dsh-mneme/test}/tools.test.js +285 -285
  136. package/{test → dsh-mneme/test}/vector-index.test.js +221 -221
  137. package/{test → dsh-mneme/test}/wiki-link.test.js +332 -332
  138. package/package.json +18 -40
  139. package//346/250/252/345/271/205.png +0 -0
@@ -1,332 +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
- });
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
+ });