@modusensus/dsh-mneme 0.7.10 → 0.7.11
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.en.md +368 -0
- package/README.md +298 -169
- package/{dsh-mneme/cordis.patch.yml → cordis.patch.yml} +1 -0
- package/{dsh-mneme/lib → lib}/api.js +50 -295
- package/{dsh-mneme/lib → lib}/client.js +780 -936
- package/{dsh-mneme/src → lib}/config.js +6 -100
- package/{dsh-mneme/lib → lib}/dream/decisions.js +94 -143
- package/{dsh-mneme/lib → lib}/dream/sleep.js +12 -152
- package/{dsh-mneme/lib → lib}/dream.js +22 -168
- package/{dsh-mneme/src → lib}/hot-memory.js +0 -7
- package/{dsh-mneme/lib → lib}/index.js +6 -34
- package/{dsh-mneme/lib → lib}/inject.js +11 -53
- package/{dsh-mneme/src → lib}/mirror.js +1 -12
- package/{dsh-mneme/lib → lib}/quality-filter.js +1 -3
- package/{dsh-mneme/lib → lib}/reranker.js +1 -4
- package/{dsh-mneme/lib → lib}/service.js +20 -410
- package/{dsh-mneme/lib → lib}/settings.js +0 -70
- package/{dsh-mneme/lib → lib}/store.js +35 -450
- package/{dsh-mneme/lib → lib}/summarize.js +10 -10
- package/lib/tools.js +274 -0
- package/package.json +41 -19
- package/{dsh-mneme/scripts → scripts}/e2e-dsh.js +3 -4
- package/{dsh-mneme/scripts → scripts}/sync-lib.js +5 -10
- package/{dsh-mneme/src → src}/api.js +50 -295
- package/{dsh-mneme/lib → src}/config.js +6 -100
- package/{dsh-mneme/src → src}/dream/decisions.js +94 -143
- package/{dsh-mneme/src → src}/dream/sleep.js +12 -152
- package/{dsh-mneme/src → src}/dream.js +22 -168
- package/{dsh-mneme/lib → src}/hot-memory.js +0 -7
- package/{dsh-mneme/src → src}/index.js +6 -34
- package/{dsh-mneme/src → src}/inject.js +11 -53
- package/{dsh-mneme/lib → src}/mirror.js +1 -12
- package/{dsh-mneme/src → src}/quality-filter.js +1 -3
- package/{dsh-mneme/src → src}/reranker.js +1 -4
- package/{dsh-mneme/src → src}/service.js +20 -410
- package/{dsh-mneme/src → src}/settings.js +0 -70
- package/{dsh-mneme/src → src}/store.js +35 -450
- package/{dsh-mneme/src → src}/summarize.js +10 -10
- package/src/tools.js +274 -0
- package/{dsh-mneme/test → test}/api.test.js +1 -156
- package/{dsh-mneme/test → test}/client.test.js +10 -206
- package/{dsh-mneme/test → test}/config.test.js +0 -21
- package/{dsh-mneme/test → test}/dream.test.js +1 -160
- package/{dsh-mneme/test → test}/graph-api.test.js +0 -34
- package/{dsh-mneme/test → test}/hot-memory.test.js +0 -29
- package/test/inject.test.js +120 -0
- package/{dsh-mneme/test → test}/llm-audit.test.js +4 -4
- package/{dsh-mneme/test → test}/reasoning-effort.test.js +0 -27
- package/{dsh-mneme/test → test}/recall-layer.test.js +5 -26
- package/{dsh-mneme/test → test}/reranker.test.js +0 -43
- package/{dsh-mneme/test → test}/service-search.test.js +0 -25
- package/{dsh-mneme/test → test}/service.test.js +0 -106
- package/{dsh-mneme/test → test}/settings.test.js +0 -43
- package/{dsh-mneme/test → test}/sleep.test.js +4 -171
- package/{dsh-mneme/test → test}/store.test.js +0 -76
- package/{dsh-mneme/test → test}/summarize.test.js +16 -15
- package/test/tools.test.js +265 -0
- package/.github/workflows/publish.yml +0 -58
- package/.github/workflows/test.yml +0 -32
- package/CHANGELOG.md +0 -91
- package/CONTRIBUTING.md +0 -293
- package/SECURITY.md +0 -718
- package/docs/devlog/2026-08-14-dsh-mneme-dev-log.md +0 -247
- package/docs/devlog/2026-08-15-dsh-mneme-audit-stress-dev-log.md +0 -145
- package/docs/devlog/2026-08-15-dsh-mneme-pipeline-dev-log.md +0 -56
- package/docs/devlog/2026-08-15-dsh-mneme-reflection-dev-log.md +0 -77
- package/docs/devlog/2026-08-15-dsh-mneme-review-fixes-dev-log.md +0 -64
- package/docs/devlog/2026-08-15-dsh-mneme-semantic-dev-log.md +0 -90
- package/dsh-mneme/CHANGELOG.md +0 -419
- package/dsh-mneme/LICENSE +0 -21
- package/dsh-mneme/README.md +0 -501
- package/dsh-mneme/docs/AGENT_MEMORY_RESEARCH.md +0 -183
- package/dsh-mneme/docs/ENTITIES.md +0 -245
- package/dsh-mneme/docs/LOCAL_MODEL.md +0 -141
- package/dsh-mneme/docs/MIGRATION.md +0 -127
- package/dsh-mneme/docs/SEMANTIC.md +0 -256
- package/dsh-mneme/docs/SLEEP.md +0 -163
- package/dsh-mneme/lib/dream/tag-extractor.js +0 -156
- package/dsh-mneme/lib/heat.js +0 -136
- package/dsh-mneme/lib/parser/tag.js +0 -59
- package/dsh-mneme/lib/parser/wiki-link.js +0 -38
- package/dsh-mneme/lib/search/tag-boost.js +0 -61
- package/dsh-mneme/lib/tools.js +0 -458
- package/dsh-mneme/package-lock.json +0 -1936
- package/dsh-mneme/package.json +0 -83
- package/dsh-mneme/scripts/check-sync.js +0 -42
- package/dsh-mneme/src/client.js +0 -2242
- package/dsh-mneme/src/dream/tag-extractor.js +0 -156
- package/dsh-mneme/src/heat.js +0 -136
- package/dsh-mneme/src/parser/tag.js +0 -59
- package/dsh-mneme/src/parser/wiki-link.js +0 -38
- package/dsh-mneme/src/search/tag-boost.js +0 -61
- package/dsh-mneme/src/tools.js +0 -458
- package/dsh-mneme/test/boundary-v0625.test.js +0 -82
- package/dsh-mneme/test/directory.test.js +0 -134
- package/dsh-mneme/test/heat.test.js +0 -148
- package/dsh-mneme/test/inject.test.js +0 -206
- package/dsh-mneme/test/layered-types-stats.test.js +0 -144
- package/dsh-mneme/test/lib-smoke.test.js +0 -109
- package/dsh-mneme/test/normalize-decisions.test.js +0 -120
- package/dsh-mneme/test/provenance.test.js +0 -103
- package/dsh-mneme/test/recall-runs.test.js +0 -93
- package/dsh-mneme/test/sleep-heat.test.js +0 -112
- package/dsh-mneme/test/tag-boost.test.js +0 -125
- package/dsh-mneme/test/tag.test.js +0 -426
- package/dsh-mneme/test/tools.test.js +0 -674
- package/dsh-mneme/test/updated-at-semantics.test.js +0 -113
- package/dsh-mneme/test/wiki-link.test.js +0 -332
- package//346/250/252/345/271/205.png +0 -0
- /package/{dsh-mneme/lib → lib}/commands.js +0 -0
- /package/{dsh-mneme/lib → lib}/dream/clustering.js +0 -0
- /package/{dsh-mneme/lib → lib}/embedding.js +0 -0
- /package/{dsh-mneme/lib → lib}/entities/extractor.js +0 -0
- /package/{dsh-mneme/lib → lib}/local-embedder.js +0 -0
- /package/{dsh-mneme/lib → lib}/search/adaptive.js +0 -0
- /package/{dsh-mneme/lib → lib}/search/bm25.js +0 -0
- /package/{dsh-mneme/lib → lib}/vector-index.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/benchmark-embed.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/benchmark-recall.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/benchmark-rerank.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/stress-dsh.js +0 -0
- /package/{dsh-mneme/src → src}/commands.js +0 -0
- /package/{dsh-mneme/src → src}/dream/clustering.js +0 -0
- /package/{dsh-mneme/src → src}/embedding.js +0 -0
- /package/{dsh-mneme/src → src}/entities/extractor.js +0 -0
- /package/{dsh-mneme/src → src}/local-embedder.js +0 -0
- /package/{dsh-mneme/src → src}/search/adaptive.js +0 -0
- /package/{dsh-mneme/src → src}/search/bm25.js +0 -0
- /package/{dsh-mneme/src → src}/vector-index.js +0 -0
- /package/{dsh-mneme/test → test}/audit.test.js +0 -0
- /package/{dsh-mneme/test → test}/benchmark.test.js +0 -0
- /package/{dsh-mneme/test → test}/clustering.test.js +0 -0
- /package/{dsh-mneme/test → test}/commands.test.js +0 -0
- /package/{dsh-mneme/test → test}/conflict-freeze.test.js +0 -0
- /package/{dsh-mneme/test → test}/entities.test.js +0 -0
- /package/{dsh-mneme/test → test}/epistemic.test.js +0 -0
- /package/{dsh-mneme/test → test}/fnew-0112.test.js +0 -0
- /package/{dsh-mneme/test → test}/fnew-03.test.js +0 -0
- /package/{dsh-mneme/test → test}/helpers/dream-mock.js +0 -0
- /package/{dsh-mneme/test → test}/local-embedder.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror-dirty.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror-edit-digest.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror-generation.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror.test.js +0 -0
- /package/{dsh-mneme/test → test}/peer-blockers.test.js +0 -0
- /package/{dsh-mneme/test → test}/policy-epoch.test.js +0 -0
- /package/{dsh-mneme/test → test}/quality-filter.test.js +0 -0
- /package/{dsh-mneme/test → test}/recall-evals.test.js +0 -0
- /package/{dsh-mneme/test → test}/receipt-chain.test.js +0 -0
- /package/{dsh-mneme/test → test}/reflection.test.js +0 -0
- /package/{dsh-mneme/test → test}/search-fusion.test.js +0 -0
- /package/{dsh-mneme/test → test}/semantic.test.js +0 -0
- /package/{dsh-mneme/test → test}/stress.test.js +0 -0
- /package/{dsh-mneme/test → test}/vector-index.test.js +0 -0
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { runSleep } from "../src/dream/sleep.js";
|
|
4
|
-
import { createStore } from "../src/store.js";
|
|
5
|
-
import { createService } from "../src/service.js";
|
|
6
|
-
|
|
7
|
-
// v0.7.0 语义修正(待办④ updated_at 不算访问):
|
|
8
|
-
// - merge/update 刷 updated_at 绝不当访问,last_accessed_at 与 updated_at 正交;
|
|
9
|
-
// - sleep 降级 ref 只用 last_accessed_at ?? created_at,杜绝 autoDream 合并
|
|
10
|
-
// 动作把"刚更新过内容"伪装成"刚被召回";
|
|
11
|
-
// - 触达数据采集(touchRecalled)由 heatEnabled 控制,不再依赖 sleepModeEnabled。
|
|
12
|
-
|
|
13
|
-
const DAY = 86400000;
|
|
14
|
-
|
|
15
|
-
function setup(config = {}) {
|
|
16
|
-
const store = createStore(":memory:");
|
|
17
|
-
const service = createService({ store, mirror: null, config });
|
|
18
|
-
return { store, service };
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function saveMemory(service, title, content, type = "project") {
|
|
22
|
-
return service.saveWithDedupe({ type, title, content, importance: 3 }).memory;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
test("store.update bumps updated_at but never last_accessed_at", () => {
|
|
26
|
-
const { store, service } = setup();
|
|
27
|
-
const mem = saveMemory(service, "项目A", "状态:进行中");
|
|
28
|
-
assert.equal(mem.last_accessed_at, undefined, "saved memory has no last_accessed_at");
|
|
29
|
-
|
|
30
|
-
const touched = service.touchLastAccess(mem.id, new Date(Date.now() - 5 * DAY).toISOString());
|
|
31
|
-
assert.equal(touched, true, "explicit touch succeeds");
|
|
32
|
-
const afterTouch = store.getById(mem.id);
|
|
33
|
-
assert.ok(afterTouch.last_accessed_at, "last_accessed_at set after touch");
|
|
34
|
-
|
|
35
|
-
const updatedAtBefore = afterTouch.updated_at;
|
|
36
|
-
store.update(mem.id, { content: "状态:已交付" });
|
|
37
|
-
const afterUpdate = store.getById(mem.id);
|
|
38
|
-
assert.notEqual(afterUpdate.updated_at, updatedAtBefore, "updated_at changed by update");
|
|
39
|
-
assert.equal(
|
|
40
|
-
afterUpdate.last_accessed_at,
|
|
41
|
-
afterTouch.last_accessed_at,
|
|
42
|
-
"last_accessed_at untouched by update (content change ≠ access)"
|
|
43
|
-
);
|
|
44
|
-
store.close();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("search bumps last_accessed_at by default (heatEnabled on) and is gated off by heatEnabled=false", async () => {
|
|
48
|
-
// Default config: heatEnabled 默认开 → last_accessed_at 被采集(与 sleep 开关解耦)。
|
|
49
|
-
const a = setup();
|
|
50
|
-
saveMemory(a.service, "量子计算", "入门");
|
|
51
|
-
await a.service.searchMemories("量子", { mode: "keyword" });
|
|
52
|
-
const rowA = a.store.getById(a.service.all()[0].id);
|
|
53
|
-
assert.ok(rowA.last_accessed_at, "touch runs with heatEnabled default on even when sleepModeEnabled off");
|
|
54
|
-
a.store.close();
|
|
55
|
-
|
|
56
|
-
// heatEnabled=false → 热路径零写入。
|
|
57
|
-
const b = setup({ heatEnabled: false });
|
|
58
|
-
saveMemory(b.service, "量子计算", "入门");
|
|
59
|
-
await b.service.searchMemories("量子", { mode: "keyword" });
|
|
60
|
-
const rowB = b.store.getById(b.service.all()[0].id);
|
|
61
|
-
assert.equal(rowB.last_accessed_at, undefined, "no touch when heatEnabled=false");
|
|
62
|
-
b.store.close();
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("sleep demotion anchors on last_accessed_at — a merge-bumped updated_at does not reset the fresh clock", async () => {
|
|
66
|
-
const { store, service } = setup();
|
|
67
|
-
const now = Date.now();
|
|
68
|
-
const idle = saveMemory(service, "冷查记忆A", "很久没被召回");
|
|
69
|
-
// 模拟一次 autoDream 合并:updated_at 刷成"现在",但真实访问在 100 天前。
|
|
70
|
-
store.update(idle.id, { content: "合并带来的内容更新" });
|
|
71
|
-
const wait = saveMemory(service, "冷查记忆B", "40 天未访问");
|
|
72
|
-
|
|
73
|
-
service.touchLastAccess(idle.id, new Date(now - 100 * DAY).toISOString());
|
|
74
|
-
service.touchLastAccess(wait.id, new Date(now - 40 * DAY).toISOString());
|
|
75
|
-
|
|
76
|
-
// runSleep 的最低配置:demotion 阶段不需要 LLM/语义,其余阶段会 skip。
|
|
77
|
-
const ctx = {
|
|
78
|
-
logger: { warn: () => {}, info: () => {} },
|
|
79
|
-
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "sleep-model" }) },
|
|
80
|
-
llm: {
|
|
81
|
-
async *stream() {
|
|
82
|
-
yield { type: "text-delta", index: 0, text: "[]" };
|
|
83
|
-
yield { type: "finish", reason: { kind: "stop" } };
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
};
|
|
87
|
-
const config = {
|
|
88
|
-
sleepModeEnabled: true,
|
|
89
|
-
sleepConflictStrictness: "normal",
|
|
90
|
-
sleepArchiveDays: 30,
|
|
91
|
-
sleepCompressDays: 90,
|
|
92
|
-
sleepPatternMinMemories: 100,
|
|
93
|
-
sleepMaxPatternPerRun: 3,
|
|
94
|
-
// v0.7.0 热联合判定:project λ=0.0008 默认太慢衰减,40/100 天热值都高于
|
|
95
|
-
// 阈值、被热闸保护——本测试只验证"updated_at 不算访问"的 ref 语义,故把
|
|
96
|
-
// λ 调快到 0.02 打开降级路径(默认保守语义由 sleep-heat.test.js 覆盖)。
|
|
97
|
-
heatTypeDecay: { project: 0.02 }
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
const result = await runSleep(ctx, service, config, { warn: () => {}, info: () => {} }, null, null);
|
|
101
|
-
|
|
102
|
-
const demotion = result.phases?.["demotion"];
|
|
103
|
-
assert.ok(demotion, "demotion phase ran");
|
|
104
|
-
// A:last_accessed 100 天前(即使 updated_at 是"现在")→ 越过 90 天归档线。
|
|
105
|
-
assert.ok(demotion.archived.includes(idle.id), "A archived by last_accessed_at (updated_at ignored)");
|
|
106
|
-
const afterA = store.getById(idle.id);
|
|
107
|
-
assert.equal(afterA.archived, true, "A is archived in store");
|
|
108
|
-
// B:last_accessed 40 天前 → 进入 30 天压缩窗口,被降级为摘要。
|
|
109
|
-
assert.ok(demotion.demoted.includes(wait.id), "B demoted in 30d compress window");
|
|
110
|
-
const afterB = store.getById(wait.id);
|
|
111
|
-
assert.ok(afterB._full_content, "B full body parked in _full_content");
|
|
112
|
-
store.close();
|
|
113
|
-
});
|
|
@@ -1,332 +0,0 @@
|
|
|
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
|
-
});
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|