@modusensus/dsh-mneme 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -9
- package/lib/client.js +2 -2
- package/lib/config.js +5 -2
- package/lib/dream/decisions.js +174 -62
- package/lib/dream.js +30 -5
- package/lib/index.js +3 -1
- package/lib/mirror.js +7 -1
- package/lib/service.js +117 -3
- package/lib/store.js +40 -0
- package/lib/tools.js +47 -4
- package/package.json +3 -1
- package/scripts/benchmark-embed.js +201 -0
- package/scripts/benchmark-rerank.js +166 -0
- package/scripts/e2e-dsh.js +216 -0
- package/scripts/stress-dsh.js +255 -0
- package/scripts/sync-lib.js +47 -0
- package/src/config.js +5 -2
- package/src/dream/decisions.js +174 -62
- package/src/dream.js +30 -5
- package/src/index.js +3 -1
- package/src/mirror.js +7 -1
- package/src/service.js +117 -3
- package/src/store.js +40 -0
- package/src/tools.js +47 -4
- package/test/api.test.js +385 -0
- package/test/audit.test.js +290 -0
- package/test/client.test.js +44 -0
- package/test/clustering.test.js +100 -0
- package/test/commands.test.js +69 -0
- package/test/config.test.js +31 -0
- package/test/dream.test.js +526 -0
- package/test/helpers/dream-mock.js +82 -0
- package/test/inject.test.js +82 -0
- package/test/local-embedder.test.js +227 -0
- package/test/mirror.test.js +249 -0
- package/test/reflection.test.js +226 -0
- package/test/reranker.test.js +197 -0
- package/test/semantic.test.js +123 -0
- package/test/service-search.test.js +169 -0
- package/test/service.test.js +198 -0
- package/test/settings.test.js +101 -0
- package/test/store.test.js +293 -0
- package/test/stress.test.js +209 -0
- package/test/summarize.test.js +156 -0
- package/test/tools.test.js +265 -0
- package/test/vector-index.test.js +205 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
import { createService } from "../src/service.js";
|
|
5
|
+
import { createVectorIndex } from "../src/vector-index.js";
|
|
6
|
+
|
|
7
|
+
// Mock embedder from the pipeline spec: every query maps to the fixed vector
|
|
8
|
+
// [1,0,0], so vector recall surfaces any row embedded at [1,0,0] (cosine 1)
|
|
9
|
+
// and ignores rows embedded elsewhere (cosine 0).
|
|
10
|
+
const embedder = {
|
|
11
|
+
embedSingle: async () => [1, 0, 0],
|
|
12
|
+
embed: async () => [1, 0, 0],
|
|
13
|
+
modelHash: "mock#1",
|
|
14
|
+
dimension: 3
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Mock reranker: scores candidates by their merge order (0,1,2,...) then sorts
|
|
18
|
+
// descending — i.e. it reverses the incoming order. Deterministic, so tests can
|
|
19
|
+
// assert the on/off difference precisely.
|
|
20
|
+
const reranker = {
|
|
21
|
+
rerank: async (q, cands) =>
|
|
22
|
+
cands.map((c, i) => ({ id: c.id, score: i })).sort((a, b) => b.score - a.score)
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Real store + service, wired with the mock embedder and a vector index over
|
|
26
|
+
// the same store (the service's vector path prefers vectorIndex when set).
|
|
27
|
+
function setup({ withEmbedder = true } = {}) {
|
|
28
|
+
const store = createStore(":memory:");
|
|
29
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
30
|
+
const vectorIndex = createVectorIndex({ store });
|
|
31
|
+
if (withEmbedder) {
|
|
32
|
+
service.setEmbedder(embedder);
|
|
33
|
+
service.setVectorIndex(vectorIndex);
|
|
34
|
+
}
|
|
35
|
+
return { store, service, vectorIndex };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("hybrid merges vector + keyword recalls and dedups shared memories", async () => {
|
|
39
|
+
const { service, vectorIndex } = setup();
|
|
40
|
+
// keyword-only hit: literal query in the title, embedding far from [1,0,0].
|
|
41
|
+
const kw = service.saveWithDedupe({ type: "preference", title: "量子计算", content: "量子计算入门", importance: 5 });
|
|
42
|
+
vectorIndex.saveEmbedding(kw.memory.id, [0, 1, 0]);
|
|
43
|
+
// vector-only hit: no literal match, but embedded exactly at the query vector.
|
|
44
|
+
const vec = service.saveWithDedupe({ type: "project", title: "猫", content: "喜欢猫" });
|
|
45
|
+
vectorIndex.saveEmbedding(vec.memory.id, [1, 0, 0]);
|
|
46
|
+
// both: literal match AND embedded at [1,0,0] -> must appear exactly once.
|
|
47
|
+
const both = service.saveWithDedupe({ type: "decision", title: "量子计算笔记", content: "量子计算实践" });
|
|
48
|
+
vectorIndex.saveEmbedding(both.memory.id, [1, 0, 0]);
|
|
49
|
+
|
|
50
|
+
const rows = await service.searchMemories("量子计算", { mode: "hybrid", topK: 10 });
|
|
51
|
+
const ids = rows.map((r) => r.id);
|
|
52
|
+
assert.ok(ids.includes(vec.memory.id), "vector recall surfaces the semantic-only hit");
|
|
53
|
+
assert.ok(ids.includes(kw.memory.id), "keyword recall surfaces the literal hit");
|
|
54
|
+
assert.equal(ids.filter((id) => id === both.memory.id).length, 1, "overlap row is deduped to a single entry");
|
|
55
|
+
assert.ok(rows.some((m) => m.id === both.memory.id && m.vector), "shared row is flagged as a vector hit");
|
|
56
|
+
assert.equal(ids[0], vec.memory.id, "vector leads the hybrid blend");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("hybrid blend honors configured vector/keyword weights", async () => {
|
|
60
|
+
const { service, vectorIndex } = setup();
|
|
61
|
+
const both = service.saveWithDedupe({ type: "preference", title: "量子计算", content: "量子计算入门" });
|
|
62
|
+
vectorIndex.saveEmbedding(both.memory.id, [1, 0, 0]);
|
|
63
|
+
|
|
64
|
+
// Title hit scores 1 * (0.5 + 3/10) = 0.8; vector cosine = 1.0.
|
|
65
|
+
// Default weights (0.6/0.4) -> 0.6 + 0.32 = 0.92.
|
|
66
|
+
const defaults = await service.searchMemories("量子计算", { mode: "hybrid", topK: 10 });
|
|
67
|
+
const d = defaults.find((m) => m.id === both.memory.id);
|
|
68
|
+
assert.ok(Math.abs(d.score - 0.92) < 1e-9, `default blend ${d.score}`);
|
|
69
|
+
|
|
70
|
+
// Even 0.5/0.5 -> 0.5 + 0.4 = 0.9.
|
|
71
|
+
const store = createStore(":memory:");
|
|
72
|
+
const tuned = createService({ store, mirror: null, config: { hybridSearchVectorWeight: 0.5, hybridSearchKeywordWeight: 0.5 } });
|
|
73
|
+
const vi = createVectorIndex({ store });
|
|
74
|
+
tuned.setEmbedder(embedder);
|
|
75
|
+
tuned.setVectorIndex(vi);
|
|
76
|
+
const m = tuned.saveWithDedupe({ type: "preference", title: "量子计算", content: "量子计算入门" });
|
|
77
|
+
vi.saveEmbedding(m.memory.id, [1, 0, 0]);
|
|
78
|
+
const rows = await tuned.searchMemories("量子计算", { mode: "hybrid", topK: 10 });
|
|
79
|
+
const t = rows.find((r) => r.id === m.memory.id);
|
|
80
|
+
assert.ok(Math.abs(t.score - 0.9) < 1e-9, `tuned blend ${t.score}`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("auto = keyword leads, vector fills the remaining slots", async () => {
|
|
84
|
+
const { service, vectorIndex } = setup();
|
|
85
|
+
const kw = service.saveWithDedupe({ type: "preference", title: "量子计算", content: "量子计算入门" });
|
|
86
|
+
vectorIndex.saveEmbedding(kw.memory.id, [0, 1, 0]);
|
|
87
|
+
const vec = service.saveWithDedupe({ type: "preference", title: "猫", content: "喜欢猫" });
|
|
88
|
+
vectorIndex.saveEmbedding(vec.memory.id, [1, 0, 0]);
|
|
89
|
+
|
|
90
|
+
const rows = await service.searchMemories("量子计算", { mode: "auto", topK: 10 });
|
|
91
|
+
const ids = rows.map((r) => r.id);
|
|
92
|
+
assert.ok(ids.includes(kw.memory.id), "keyword hit present");
|
|
93
|
+
assert.ok(ids.includes(vec.memory.id), "vector fills a free slot");
|
|
94
|
+
assert.equal(ids[0], kw.memory.id, "keyword stays first in auto mode");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("reranker on/off: rerank reorders by score, off keeps merge order", async () => {
|
|
98
|
+
const { service, vectorIndex } = setup();
|
|
99
|
+
const a = service.saveWithDedupe({ type: "preference", title: "猫", content: "喜欢猫", importance: 5 });
|
|
100
|
+
vectorIndex.saveEmbedding(a.memory.id, [0, 1, 0]);
|
|
101
|
+
const b = service.saveWithDedupe({ type: "preference", title: "猫猫", content: "猫粮", importance: 3 });
|
|
102
|
+
vectorIndex.saveEmbedding(b.memory.id, [0, 1, 0]);
|
|
103
|
+
|
|
104
|
+
// No reranker: keyword order (importance desc) is preserved.
|
|
105
|
+
const off = await service.searchMemories("猫", { mode: "keyword", topK: 10 });
|
|
106
|
+
assert.equal(off[0].id, a.memory.id, "without a reranker merge order is kept");
|
|
107
|
+
|
|
108
|
+
// Mock reranker installed: it reverses the merge order by score.
|
|
109
|
+
service.setReranker(reranker);
|
|
110
|
+
const on = await service.searchMemories("猫", { mode: "keyword", topK: 10 });
|
|
111
|
+
assert.equal(on[0].id, b.memory.id, "rerank reorders to the highest score first");
|
|
112
|
+
assert.equal(typeof on[0].score, "number", "reranked rows carry a score");
|
|
113
|
+
|
|
114
|
+
// Clearing the reranker restores merge order.
|
|
115
|
+
service.setReranker(null);
|
|
116
|
+
const after = await service.searchMemories("猫", { mode: "keyword", topK: 10 });
|
|
117
|
+
assert.equal(after[0].id, a.memory.id, "clearing the reranker restores original order");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("keyword mode never calls the embedder", async () => {
|
|
121
|
+
let calls = 0;
|
|
122
|
+
const counting = { ...embedder, embedSingle: async () => { calls++; return [1, 0, 0]; } };
|
|
123
|
+
const { service, vectorIndex } = setup();
|
|
124
|
+
service.setEmbedder(counting);
|
|
125
|
+
service.setVectorIndex(vectorIndex);
|
|
126
|
+
service.saveWithDedupe({ type: "preference", title: "语言", content: "中文交流" });
|
|
127
|
+
|
|
128
|
+
const rows = await service.searchMemories("中文", { mode: "keyword", topK: 10 });
|
|
129
|
+
assert.equal(rows.length, 1);
|
|
130
|
+
assert.equal(rows[0].title, "语言");
|
|
131
|
+
assert.equal(calls, 0, "keyword mode must never embed the query");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("embedder failure degrades to keyword results", async () => {
|
|
135
|
+
const broken = { ...embedder, embedSingle: async () => { throw new Error("model unavailable"); } };
|
|
136
|
+
const { service, vectorIndex } = setup();
|
|
137
|
+
service.setEmbedder(broken);
|
|
138
|
+
service.setVectorIndex(vectorIndex);
|
|
139
|
+
service.saveWithDedupe({ type: "preference", title: "语言", content: "中文交流" });
|
|
140
|
+
|
|
141
|
+
const rows = await service.searchMemories("中文", { mode: "auto", topK: 10 });
|
|
142
|
+
assert.equal(rows.length, 1, "keyword fallback still returns matches");
|
|
143
|
+
assert.equal(rows[0].title, "语言");
|
|
144
|
+
assert.ok(!rows.some((m) => m.vector), "no rows claim the vector path after a failure");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("vector mode returns semantic hits, and falls back to keyword without an embedder", async () => {
|
|
148
|
+
const { service, vectorIndex } = setup();
|
|
149
|
+
const hit = service.saveWithDedupe({ type: "preference", title: "猫", content: "喜欢猫" });
|
|
150
|
+
vectorIndex.saveEmbedding(hit.memory.id, [1, 0, 0]);
|
|
151
|
+
const rows = await service.searchMemories("猫", { mode: "vector", topK: 10 });
|
|
152
|
+
assert.ok(rows.some((m) => m.id === hit.memory.id && m.vector), "vector-only recall surfaces the embedded hit");
|
|
153
|
+
|
|
154
|
+
// No embedder/vector index: vector mode falls back to literal keyword.
|
|
155
|
+
const bare = setup({ withEmbedder: false });
|
|
156
|
+
const m = bare.service.saveWithDedupe({ type: "preference", title: "语言", content: "中文交流" });
|
|
157
|
+
const fallback = await bare.service.searchMemories("中文", { mode: "vector", topK: 10 });
|
|
158
|
+
assert.ok(fallback.some((x) => x.id === m.memory.id), "keyword fallback when vector is unavailable");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("topK caps the merged result set", async () => {
|
|
162
|
+
const { service, vectorIndex } = setup();
|
|
163
|
+
for (let i = 0; i < 5; i++) {
|
|
164
|
+
const m = service.saveWithDedupe({ type: "preference", title: `猫${i}`, content: "猫" });
|
|
165
|
+
vectorIndex.saveEmbedding(m.memory.id, [1, 0, 0]);
|
|
166
|
+
}
|
|
167
|
+
const rows = await service.searchMemories("猫", { mode: "auto", topK: 3 });
|
|
168
|
+
assert.equal(rows.length, 3, "merged list is trimmed to topK");
|
|
169
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createStore } from "../src/store.js";
|
|
7
|
+
import { createService } from "../src/service.js";
|
|
8
|
+
import { createMirror } from "../src/mirror.js";
|
|
9
|
+
|
|
10
|
+
function setup() {
|
|
11
|
+
const store = createStore(":memory:");
|
|
12
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
13
|
+
return { store, service };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("saveWithDedupe adds new memory when no similar exists", () => {
|
|
17
|
+
const { store, service } = setup();
|
|
18
|
+
const result = service.saveWithDedupe({
|
|
19
|
+
type: "project",
|
|
20
|
+
title: "记忆插件",
|
|
21
|
+
content: "正在开发 dsh-mneme,SQLite+Markdown",
|
|
22
|
+
importance: 4
|
|
23
|
+
});
|
|
24
|
+
assert.equal(result.action, "created");
|
|
25
|
+
assert.equal(store.count(), 1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("saveWithDedupe merges into existing on exact title match", () => {
|
|
29
|
+
const { store, service } = setup();
|
|
30
|
+
service.saveWithDedupe({ type: "project", title: "记忆插件", content: "旧内容", importance: 3 });
|
|
31
|
+
const result = service.saveWithDedupe({ type: "project", title: "记忆插件", content: "新内容", importance: 5 });
|
|
32
|
+
assert.equal(result.action, "merged");
|
|
33
|
+
assert.equal(store.count(), 1);
|
|
34
|
+
const all = store.all();
|
|
35
|
+
assert.equal(all[0].content, "新内容");
|
|
36
|
+
assert.equal(all[0].importance, 5);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("injectCandidates returns preferences + high-importance items within limit", () => {
|
|
40
|
+
const { service } = setup();
|
|
41
|
+
for (let i = 0; i < 3; i++) service.saveWithDedupe({ type: "preference", title: `p${i}`, content: "偏好" });
|
|
42
|
+
service.saveWithDedupe({ type: "project", title: "low", content: "低", importance: 2 });
|
|
43
|
+
service.saveWithDedupe({ type: "project", title: "high", content: "高", importance: 5 });
|
|
44
|
+
service.saveWithDedupe({ type: "history", title: "h", content: "历史", importance: 5 });
|
|
45
|
+
const candidates = service.injectCandidates({ maxItems: 5, threshold: 4 });
|
|
46
|
+
const types = candidates.map((c) => c.type);
|
|
47
|
+
assert.ok(types.includes("preference"), "preferences included");
|
|
48
|
+
assert.ok(types.includes("project"), "high-importance project included");
|
|
49
|
+
assert.ok(!types.includes("history"), "history excluded by default");
|
|
50
|
+
assert.ok(candidates.length <= 5, "respects maxItems");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("mergeHumanEdits applies human content over machine", () => {
|
|
54
|
+
const { store, service } = setup();
|
|
55
|
+
const saved = service.saveWithDedupe({ type: "preference", title: "语言", content: "机器内容" });
|
|
56
|
+
const edits = [{ id: saved.memory.id, title: "语言", content: "人类编辑内容" }];
|
|
57
|
+
service.mergeHumanEdits("preference", edits);
|
|
58
|
+
const got = store.getById(saved.memory.id);
|
|
59
|
+
assert.equal(got.content, "人类编辑内容");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("toApiList maps store rows to wire DTOs", () => {
|
|
63
|
+
const { service } = setup();
|
|
64
|
+
const saved = service.saveWithDedupe({ type: "decision", title: "选型", content: "node:sqlite", importance: 3 });
|
|
65
|
+
const dto = service.toApiList([saved.memory]);
|
|
66
|
+
assert.deepEqual(Object.keys(dto[0]).sort(), ["id", "type", "title", "content", "tags", "importance", "source", "created_at", "updated_at"].sort());
|
|
67
|
+
assert.equal(dto[0].id, saved.memory.id);
|
|
68
|
+
assert.equal(dto[0].type, "decision");
|
|
69
|
+
assert.equal(dto[0].title, "选型");
|
|
70
|
+
assert.equal(dto[0].content, "node:sqlite");
|
|
71
|
+
assert.equal(dto[0].importance, 3);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("mergeHumanEdits skips edits without id and keeps applying the rest", () => {
|
|
75
|
+
const { store, service } = setup();
|
|
76
|
+
const first = service.saveWithDedupe({ type: "preference", title: "语言", content: "机器内容" });
|
|
77
|
+
const second = service.saveWithDedupe({ type: "preference", title: "主题", content: "机器内容" });
|
|
78
|
+
const edits = [
|
|
79
|
+
{ content: "缺少 id 的损坏条目" },
|
|
80
|
+
{ id: first.memory.id, title: "语言", content: "人类编辑内容" },
|
|
81
|
+
{ id: second.memory.id, title: "主题", content: "第二个人类编辑" }
|
|
82
|
+
];
|
|
83
|
+
const applied = service.mergeHumanEdits("preference", edits);
|
|
84
|
+
assert.equal(applied, 2, "returns count of applied edits, not input length");
|
|
85
|
+
assert.equal(store.getById(first.memory.id).content, "人类编辑内容");
|
|
86
|
+
assert.equal(store.getById(second.memory.id).content, "第二个人类编辑");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("mutations through passthroughs sync mirror; forgotten entries stay out of it", () => {
|
|
90
|
+
const store = createStore(":memory:");
|
|
91
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-service-"));
|
|
92
|
+
const mirror = createMirror(dir);
|
|
93
|
+
const service = createService({ store, mirror, config: {} });
|
|
94
|
+
try {
|
|
95
|
+
const saved = service.saveWithDedupe({ type: "project", title: "A", content: "a", importance: 4 });
|
|
96
|
+
assert.ok(existsSync(join(dir, "projects.md")), "mirror written on save");
|
|
97
|
+
|
|
98
|
+
service.update(saved.memory.id, { content: "a2" });
|
|
99
|
+
assert.match(readFileSync(join(dir, "projects.md"), "utf8"), /a2/, "update re-syncs mirror");
|
|
100
|
+
|
|
101
|
+
service.setForget(saved.memory.id, true);
|
|
102
|
+
assert.ok(!existsSync(join(dir, "projects.md")), "forgotten entry removed from mirror");
|
|
103
|
+
|
|
104
|
+
const second = service.saveWithDedupe({ type: "project", title: "B", content: "b", importance: 2 });
|
|
105
|
+
assert.ok(existsSync(join(dir, "projects.md")), "mirror rewritten after re-save");
|
|
106
|
+
service.remove(second.memory.id);
|
|
107
|
+
assert.ok(!existsSync(join(dir, "projects.md")), "remove re-syncs mirror");
|
|
108
|
+
} finally {
|
|
109
|
+
rmSync(dir, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("saveWithDedupe created branch keeps forgotten entries out of mirror", () => {
|
|
114
|
+
const store = createStore(":memory:");
|
|
115
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-service-"));
|
|
116
|
+
const mirror = createMirror(dir);
|
|
117
|
+
const service = createService({ store, mirror, config: {} });
|
|
118
|
+
try {
|
|
119
|
+
const a = service.saveWithDedupe({ type: "project", title: "A", content: "a", importance: 4 });
|
|
120
|
+
service.setForget(a.memory.id, true);
|
|
121
|
+
service.saveWithDedupe({ type: "project", title: "B", content: "b", importance: 2 });
|
|
122
|
+
const text = readFileSync(join(dir, "projects.md"), "utf8");
|
|
123
|
+
assert.ok(!text.includes(a.memory.id), "forgotten A must not reappear in mirror");
|
|
124
|
+
assert.ok(!text.includes("## A"), "forgotten A entry absent from mirror");
|
|
125
|
+
assert.match(text, /## B/, "non-forgotten B present in mirror");
|
|
126
|
+
} finally {
|
|
127
|
+
rmSync(dir, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("injectCandidates excludes archived entries", () => {
|
|
132
|
+
const { store, service } = setup();
|
|
133
|
+
const saved = service.saveWithDedupe({ type: "preference", title: "旧偏好", content: "old", importance: 5 });
|
|
134
|
+
store.setArchived(saved.memory.id, true);
|
|
135
|
+
const candidates = service.injectCandidates({ maxItems: 5, threshold: 3 });
|
|
136
|
+
assert.ok(!candidates.some((c) => c.id === saved.memory.id), "archived excluded");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("summary memory is a candidate at top priority", () => {
|
|
140
|
+
const { service } = setup();
|
|
141
|
+
service.saveWithDedupe({ type: "preference", title: "语言", content: "中文", importance: 5 });
|
|
142
|
+
service.saveWithDedupe({ type: "summary", title: "记忆库总览", content: "总览内容", importance: 5 });
|
|
143
|
+
const candidates = service.injectCandidates({ maxItems: 5, threshold: 3 });
|
|
144
|
+
assert.equal(candidates[0]?.type, "summary", "summary first even with competing high-importance preference");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("write methods invoke onWrite hook when provided", () => {
|
|
148
|
+
const { store } = setup();
|
|
149
|
+
let called = 0;
|
|
150
|
+
const svc = createService({ store, mirror: null, config: {}, onWrite: () => { called++; } });
|
|
151
|
+
svc.saveWithDedupe({ type: "project", title: "a", content: "x" });
|
|
152
|
+
assert.equal(called, 1, "saveWithDedupe hooks");
|
|
153
|
+
svc.update(svc.all()[0].id, { content: "y" });
|
|
154
|
+
assert.equal(called, 2, "update hooks");
|
|
155
|
+
svc.setArchived(svc.all()[0].id, true);
|
|
156
|
+
assert.equal(called, 2, "setArchived does not hook (not a content write)");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// --- transactions (item ②) + compare-and-set (item ①/③) --------------------
|
|
160
|
+
|
|
161
|
+
test("service.transaction rolls back every step when the body throws", () => {
|
|
162
|
+
const { store, service } = setup();
|
|
163
|
+
let threw = false;
|
|
164
|
+
try {
|
|
165
|
+
service.transaction(() => {
|
|
166
|
+
service.saveWithDedupe({ type: "project", title: "原子A", content: "x" });
|
|
167
|
+
service.saveWithDedupe({ type: "project", title: "原子B", content: "y" });
|
|
168
|
+
throw new Error("boom");
|
|
169
|
+
});
|
|
170
|
+
} catch { threw = true; }
|
|
171
|
+
assert.equal(threw, true, "error propagates");
|
|
172
|
+
assert.equal(store.count(), 0, "no partial writes after rollback");
|
|
173
|
+
assert.ok(!store.all().some((m) => m.title === "原子A"), "step A rolled back");
|
|
174
|
+
assert.ok(!store.all().some((m) => m.title === "原子B"), "step B rolled back");
|
|
175
|
+
store.close();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("service.transaction commits all steps atomically on success", () => {
|
|
179
|
+
const { store, service } = setup();
|
|
180
|
+
service.transaction(() => {
|
|
181
|
+
service.saveWithDedupe({ type: "project", title: "甲", content: "x" });
|
|
182
|
+
service.saveWithDedupe({ type: "project", title: "乙", content: "y" });
|
|
183
|
+
});
|
|
184
|
+
assert.equal(store.count(), 2, "both steps committed");
|
|
185
|
+
store.close();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("service.compareAndUpdate rejects a stale version token (no lost update)", () => {
|
|
189
|
+
const { store, service } = setup();
|
|
190
|
+
const { memory: m } = service.saveWithDedupe({ type: "history", title: "计数器", content: "count=0" });
|
|
191
|
+
const baseline = service.getById(m.id);
|
|
192
|
+
const first = service.compareAndUpdate(m.id, baseline.updated_at, { content: "count=1" });
|
|
193
|
+
assert.ok(first, "current version CAS succeeds");
|
|
194
|
+
const stale = service.compareAndUpdate(m.id, baseline.updated_at, { content: "count=2" });
|
|
195
|
+
assert.equal(stale, undefined, "stale version CAS misses without writing");
|
|
196
|
+
assert.equal(service.getById(m.id).content, "count=1", "increment not lost");
|
|
197
|
+
store.close();
|
|
198
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
import { createSettings } from "../src/settings.js";
|
|
5
|
+
|
|
6
|
+
function setup() {
|
|
7
|
+
const store = createStore(":memory:");
|
|
8
|
+
const settings = createSettings(store.db);
|
|
9
|
+
return { store, settings };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
test("profile defaults to empty and round-trips", () => {
|
|
13
|
+
const { store, settings } = setup();
|
|
14
|
+
assert.equal(settings.getProfile(), "");
|
|
15
|
+
settings.setProfile("我是后端工程师,偏好简洁代码");
|
|
16
|
+
assert.equal(settings.getProfile(), "我是后端工程师,偏好简洁代码");
|
|
17
|
+
settings.setProfile("");
|
|
18
|
+
assert.equal(settings.getProfile(), "");
|
|
19
|
+
store.close();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("rules default to empty list and round-trip", () => {
|
|
23
|
+
const { store, settings } = setup();
|
|
24
|
+
assert.deepEqual(settings.getRules(), []);
|
|
25
|
+
settings.setRules(["规则一", "规则二"]);
|
|
26
|
+
assert.deepEqual(settings.getRules(), ["规则一", "规则二"]);
|
|
27
|
+
settings.setRules([]);
|
|
28
|
+
assert.deepEqual(settings.getRules(), []);
|
|
29
|
+
store.close();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("rules filters non-string entries", () => {
|
|
33
|
+
const { store, settings } = setup();
|
|
34
|
+
settings.setRules(["ok", 42, null, "also-ok"]);
|
|
35
|
+
assert.deepEqual(settings.getRules(), ["ok", "also-ok"]);
|
|
36
|
+
store.close();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("addCommand stores and lists sorted by name", () => {
|
|
40
|
+
const { store, settings } = setup();
|
|
41
|
+
settings.addCommand({ name: "banner", description: "b", instruction: "打印横幅" });
|
|
42
|
+
settings.addCommand({ name: "agenda", instruction: "列出今日议程" });
|
|
43
|
+
const list = settings.listCommands();
|
|
44
|
+
assert.equal(list.length, 2);
|
|
45
|
+
assert.deepEqual(list.map((c) => c.name), ["agenda", "banner"]);
|
|
46
|
+
assert.ok(list[1].id, "has id");
|
|
47
|
+
store.close();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("addCommand replaces existing name", () => {
|
|
51
|
+
const { store, settings } = setup();
|
|
52
|
+
settings.addCommand({ name: "fmt", instruction: "旧指令" });
|
|
53
|
+
const updated = settings.addCommand({ name: "fmt", instruction: "新指令" });
|
|
54
|
+
assert.equal(updated.instruction, "新指令");
|
|
55
|
+
assert.equal(settings.listCommands().length, 1);
|
|
56
|
+
store.close();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("addCommand rejects invalid names and empty instructions", () => {
|
|
60
|
+
const { store, settings } = setup();
|
|
61
|
+
assert.throws(() => settings.addCommand({ name: "Bad Name", instruction: "x" }), /command name/);
|
|
62
|
+
assert.throws(() => settings.addCommand({ name: "123abc", instruction: "x" }), /command name/);
|
|
63
|
+
assert.throws(() => settings.addCommand({ name: "ok", instruction: " " }), /instruction/);
|
|
64
|
+
assert.equal(settings.listCommands().length, 0);
|
|
65
|
+
store.close();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("removeCommand removes by id and returns false for missing", () => {
|
|
69
|
+
const { store, settings } = setup();
|
|
70
|
+
const { id } = settings.addCommand({ name: "tmp", instruction: "x" });
|
|
71
|
+
assert.equal(settings.removeCommand(id), true);
|
|
72
|
+
assert.equal(settings.removeCommand(id), false);
|
|
73
|
+
assert.equal(settings.listCommands().length, 0);
|
|
74
|
+
store.close();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("vector config defaults to undefined and round-trips", () => {
|
|
78
|
+
const { store, settings } = setup();
|
|
79
|
+
assert.equal(settings.getVectorConfig(), undefined);
|
|
80
|
+
const saved = settings.setVectorConfig({
|
|
81
|
+
enabled: true,
|
|
82
|
+
baseUrl: " https://api.openai.com/v1/ ",
|
|
83
|
+
apiKey: " sk-123 ",
|
|
84
|
+
model: "text-embedding-3-small"
|
|
85
|
+
});
|
|
86
|
+
assert.equal(saved.enabled, true);
|
|
87
|
+
assert.equal(saved.baseUrl, "https://api.openai.com/v1", "trims baseUrl");
|
|
88
|
+
assert.equal(saved.apiKey, "sk-123", "trims apiKey");
|
|
89
|
+
assert.equal(saved.model, "text-embedding-3-small");
|
|
90
|
+
const read = settings.getVectorConfig();
|
|
91
|
+
assert.equal(read.enabled, true);
|
|
92
|
+
assert.equal(read.model, "text-embedding-3-small");
|
|
93
|
+
store.close();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("vector config disabled value is stored as false", () => {
|
|
97
|
+
const { store, settings } = setup();
|
|
98
|
+
settings.setVectorConfig({ enabled: false, baseUrl: "x", apiKey: "k", model: "m" });
|
|
99
|
+
assert.equal(settings.getVectorConfig().enabled, false);
|
|
100
|
+
store.close();
|
|
101
|
+
});
|