@modusensus/dsh-mneme 0.4.4 → 0.4.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 +27 -0
- package/lib/api.js +40 -2
- package/lib/config.js +53 -0
- package/lib/dream/decisions.js +43 -2
- package/lib/dream.js +86 -6
- package/lib/embedding.js +59 -2
- package/lib/index.js +68 -1
- package/lib/inject.js +84 -4
- package/lib/quality-filter.js +123 -0
- package/lib/service.js +355 -14
- package/lib/store.js +366 -5
- package/lib/summarize.js +65 -7
- package/lib/vector-index.js +12 -2
- package/package.json +1 -1
- package/src/api.js +40 -2
- package/src/config.js +53 -0
- package/src/dream/decisions.js +43 -2
- package/src/dream.js +86 -6
- package/src/embedding.js +59 -2
- package/src/index.js +68 -1
- package/src/inject.js +84 -4
- package/src/quality-filter.js +123 -0
- package/src/service.js +355 -14
- package/src/store.js +366 -5
- package/src/summarize.js +65 -7
- package/src/vector-index.js +12 -2
- package/test/api.test.js +84 -0
- package/test/dream.test.js +52 -0
- package/test/epistemic.test.js +298 -0
- package/test/inject.test.js +21 -0
- package/test/llm-audit.test.js +279 -0
- package/test/mirror-edit-digest.test.js +3 -1
- package/test/quality-filter.test.js +118 -0
- package/test/recall-evals.test.js +235 -0
- package/test/service.test.js +133 -2
- package/test/vector-index.test.js +22 -6
package/test/service.test.js
CHANGED
|
@@ -32,8 +32,15 @@ test("saveWithDedupe merges into existing on exact title match", () => {
|
|
|
32
32
|
assert.equal(result.action, "merged");
|
|
33
33
|
assert.equal(store.count(), 1);
|
|
34
34
|
const all = store.all();
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
// Bug5: same-title merge appends under a timestamped `---` separator instead
|
|
36
|
+
// of overwriting; the previous content is archived into content_history.
|
|
37
|
+
assert.ok(all[0].content.includes("旧内容"), "old content preserved in appended body");
|
|
38
|
+
assert.ok(all[0].content.includes("新内容"), "new content appended");
|
|
39
|
+
assert.ok(all[0].content.includes("---"), "timestamped separator present");
|
|
40
|
+
assert.equal(all[0].importance, 5, "importance takes the max of both");
|
|
41
|
+
assert.equal(all[0].content_history.length, 1, "old content archived to history");
|
|
42
|
+
assert.equal(all[0].content_history[0].content, "旧内容");
|
|
43
|
+
assert.equal(all[0].content_history[0].source, "auto_merge");
|
|
37
44
|
});
|
|
38
45
|
|
|
39
46
|
test("injectCandidates returns preferences + high-importance items within limit", () => {
|
|
@@ -196,3 +203,127 @@ test("service.compareAndUpdate rejects a stale version token (no lost update)",
|
|
|
196
203
|
assert.equal(service.getById(m.id).content, "count=1", "increment not lost");
|
|
197
204
|
store.close();
|
|
198
205
|
});
|
|
206
|
+
|
|
207
|
+
// --- Bug4: injectCandidates semantic-first recall (query) --------------------
|
|
208
|
+
|
|
209
|
+
test("Bug4: injectCandidates with an empty query keeps the legacy rule-based selection", () => {
|
|
210
|
+
const { service } = setup();
|
|
211
|
+
service.saveWithDedupe({ type: "preference", title: "p0", content: "偏好" });
|
|
212
|
+
service.saveWithDedupe({ type: "preference", title: "p1", content: "偏好" });
|
|
213
|
+
service.saveWithDedupe({ type: "project", title: "low", content: "低", importance: 2 });
|
|
214
|
+
service.saveWithDedupe({ type: "project", title: "high", content: "高", importance: 5 });
|
|
215
|
+
service.saveWithDedupe({ type: "history", title: "h", content: "历史", importance: 5 });
|
|
216
|
+
service.saveWithDedupe({ type: "summary", title: "记忆库总览", content: "总览", importance: 5 });
|
|
217
|
+
const legacy = service.injectCandidates({ maxItems: 5, threshold: 4 });
|
|
218
|
+
const noQuery = service.injectCandidates({ maxItems: 5, threshold: 4, query: "" });
|
|
219
|
+
const blankQuery = service.injectCandidates({ maxItems: 5, threshold: 4, query: " " });
|
|
220
|
+
assert.deepEqual(noQuery.map((c) => c.id), legacy.map((c) => c.id), "empty query behaves identically to the old logic");
|
|
221
|
+
assert.deepEqual(blankQuery.map((c) => c.id), legacy.map((c) => c.id), "whitespace-only query degrades too");
|
|
222
|
+
assert.equal(legacy[0].type, "summary", "summary still leads");
|
|
223
|
+
assert.ok(!legacy.some((c) => c.type === "history"), "history still excluded");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("Bug4: injectCandidates ignores the query when hybridInject is disabled", () => {
|
|
227
|
+
const { store } = setup();
|
|
228
|
+
const svc = createService({ store, mirror: null, config: { hybridInject: false } });
|
|
229
|
+
svc.saveWithDedupe({ type: "preference", title: "p0", content: "偏好" });
|
|
230
|
+
svc.saveWithDedupe({ type: "summary", title: "记忆库总览", content: "总览", importance: 5 });
|
|
231
|
+
const noQuery = svc.injectCandidates({ maxItems: 5, threshold: 3 });
|
|
232
|
+
const withQuery = svc.injectCandidates({ maxItems: 5, threshold: 3, query: "中文" });
|
|
233
|
+
assert.deepEqual(withQuery.map((c) => c.id), noQuery.map((c) => c.id), "hybridInject off keeps rule-based ordering");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// --- Bug5: content_history FIFO + human-edited direct overwrite --------------
|
|
237
|
+
|
|
238
|
+
test("Bug5: content_history grows FIFO across repeated same-title merges (capped at 20)", () => {
|
|
239
|
+
const { store, service } = setup();
|
|
240
|
+
service.saveWithDedupe({ type: "project", title: "流水", content: "v0" });
|
|
241
|
+
for (let i = 1; i <= 25; i++) {
|
|
242
|
+
service.saveWithDedupe({ type: "project", title: "流水", content: `v${i}` });
|
|
243
|
+
}
|
|
244
|
+
const row = store.all()[0];
|
|
245
|
+
assert.ok(Array.isArray(row.content_history), "content_history is an array");
|
|
246
|
+
assert.equal(row.content_history.length, 20, "FIFO cap of 20 respected");
|
|
247
|
+
assert.ok(row.content_history[0].content.includes("v24"), "newest archived body kept at index 0");
|
|
248
|
+
assert.ok(row.content_history[19].content.includes("v5"), "oldest kept entry");
|
|
249
|
+
assert.ok(!row.content_history[19].content.includes("v6"), "entries older than the cap dropped");
|
|
250
|
+
assert.ok(row.content.includes("v25"), "latest merge still appended to the live body");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("Bug5: _humanEdited overwrite replaces content directly and archives the old version", () => {
|
|
254
|
+
const { store, service } = setup();
|
|
255
|
+
service.saveWithDedupe({ type: "preference", title: "语言", content: "机器内容" });
|
|
256
|
+
const result = service.saveWithDedupe({ type: "preference", title: "语言", content: "人工修正", _humanEdited: true });
|
|
257
|
+
const row = store.getById(result.memory.id);
|
|
258
|
+
assert.equal(row.content, "人工修正", "direct overwrite, no appended --- block");
|
|
259
|
+
assert.ok(!row.content.includes("机器内容"), "old content not in the live body");
|
|
260
|
+
assert.equal(row.content_history.length, 1);
|
|
261
|
+
assert.equal(row.content_history[0].content, "机器内容");
|
|
262
|
+
assert.equal(row.content_history[0].source, "human_override");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// --- Bug7: memory quality filter ---------------------------------------------
|
|
266
|
+
|
|
267
|
+
function qualitySetup(config = {}) {
|
|
268
|
+
const store = createStore(":memory:");
|
|
269
|
+
const service = createService({ store, mirror: null, config: { memoryQualityFilter: { enabled: true, ...config } } });
|
|
270
|
+
return { store, service };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
test("Bug7: meta-memory is scored below the degrade threshold but still stored", () => {
|
|
274
|
+
const { store, service } = qualitySetup();
|
|
275
|
+
const result = service.saveWithDedupe({ type: "project", title: "记忆系统规则", content: "我需要记住用户喜欢中文阅读长篇小说和散文" });
|
|
276
|
+
assert.equal(result.action, "created");
|
|
277
|
+
assert.ok(result.memory.quality_score < 60, `meta-memory degraded, got ${result.memory.quality_score}`);
|
|
278
|
+
assert.ok(result.memory.quality_score >= 30, "still above the archive threshold");
|
|
279
|
+
assert.equal(result.memory.archived, false, "not archived");
|
|
280
|
+
assert.equal(store.count(), 1, "still stored and searchable");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("Bug7: short content is archived and tagged low_quality", () => {
|
|
284
|
+
const { store, service } = qualitySetup();
|
|
285
|
+
const result = service.saveWithDedupe({ type: "project", title: "备忘", content: "x" });
|
|
286
|
+
const row = store.getById(result.memory.id);
|
|
287
|
+
assert.equal(row.archived, true, "short content archived");
|
|
288
|
+
assert.ok(row.tags.includes("low_quality"), "low_quality tag added");
|
|
289
|
+
assert.ok(row.quality_score < 30, `score below archive threshold: ${row.quality_score}`);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("Bug7: near-duplicate content is archived and tagged low_quality", () => {
|
|
293
|
+
const { store, service } = qualitySetup();
|
|
294
|
+
const base = "今天下午去操场跑步三圈然后回来洗澡";
|
|
295
|
+
service.saveWithDedupe({ type: "history", title: "记录A", content: base });
|
|
296
|
+
const result = service.saveWithDedupe({ type: "history", title: "记录B", content: `${base}${base}${base}` });
|
|
297
|
+
const row = store.getById(result.memory.id);
|
|
298
|
+
assert.equal(row.archived, true, "near-duplicate archived");
|
|
299
|
+
assert.ok(row.tags.includes("low_quality"), "low_quality tag added");
|
|
300
|
+
assert.ok(row.quality_score < 30, `score below archive threshold: ${row.quality_score}`);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("Bug7: repetitive (dedup<0.3) content is scored into the degraded band", () => {
|
|
304
|
+
const { store, service } = qualitySetup();
|
|
305
|
+
const result = service.saveWithDedupe({ type: "history", title: "唠叨", content: "好好好好好好好好好好好好好好好好好好" });
|
|
306
|
+
const row = store.getById(result.memory.id);
|
|
307
|
+
assert.ok(row.quality_score >= 30 && row.quality_score < 60, `repetitive-only degraded, got ${row.quality_score}`);
|
|
308
|
+
assert.equal(row.archived, false, "not archived (only a single non-meta signal)");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("Bug7: memoryQualityFilter.enabled=false skips scoring entirely", () => {
|
|
312
|
+
const store = createStore(":memory:");
|
|
313
|
+
const service = createService({ store, mirror: null, config: { memoryQualityFilter: { enabled: false } } });
|
|
314
|
+
const result = service.saveWithDedupe({ type: "project", title: "记忆系统规则", content: "我需要记住用户喜欢中文阅读长篇小说和散文" });
|
|
315
|
+
const row = store.getById(result.memory.id);
|
|
316
|
+
assert.ok(row.quality_score == null, "no quality_score written");
|
|
317
|
+
assert.equal(row.archived, false, "not archived");
|
|
318
|
+
assert.ok(!(row.tags ?? []).includes("low_quality"), "no low_quality tag");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("Bug7: injection ranking re-weights by quality_score (degraded memories rank lower)", () => {
|
|
322
|
+
const { store } = setup();
|
|
323
|
+
const svc = createService({ store, mirror: null, config: { memoryQualityFilter: { enabled: true } } });
|
|
324
|
+
svc.saveWithDedupe({ type: "preference", title: "高质量", content: "用户喜欢读科幻小说和散文" });
|
|
325
|
+
svc.saveWithDedupe({ type: "preference", title: "元记忆", content: "我需要记住用户偏好的完整清单防止忘记" });
|
|
326
|
+
const candidates = svc.injectCandidates({ maxItems: 5, threshold: 3 });
|
|
327
|
+
assert.equal(candidates[0].title, "高质量", "100-quality preference leads the degraded one");
|
|
328
|
+
assert.equal(candidates[1].title, "元记忆");
|
|
329
|
+
});
|
|
@@ -21,9 +21,9 @@ function addMemory(store, { title, content, type = "preference", vector }) {
|
|
|
21
21
|
return memory;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
// Embedder mock for rebuildIndex.
|
|
25
|
-
//
|
|
26
|
-
//
|
|
24
|
+
// Embedder mock for rebuildIndex. The rebuild loop prefers embedSingle and
|
|
25
|
+
// falls back to `embed` for embed-only OpenAI-compatible clients, so a mock
|
|
26
|
+
// that provides embedSingle exercises the primary path.
|
|
27
27
|
function rebuildEmbedder({ vector = [1, 0, 0], modelHash = "mock#rebuild", dimension = 3 } = {}) {
|
|
28
28
|
return {
|
|
29
29
|
embed: async (texts) => texts.map(() => vector),
|
|
@@ -137,15 +137,31 @@ test("rebuildIndex handles a throwing embedder without failing the batch", async
|
|
|
137
137
|
assert.equal(vectorIndex.getStats().embeddedCount, 1);
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
-
test("rebuildIndex guard: embedder
|
|
140
|
+
test("rebuildIndex guard: embedder with no embed method at all is ignored", async () => {
|
|
141
141
|
const { store, vectorIndex } = setup();
|
|
142
142
|
addMemory(store, { title: "缺向量", content: "A" });
|
|
143
|
-
const bare = {
|
|
143
|
+
const bare = { modelHash: "mock#bare", dimension: 3 };
|
|
144
144
|
const result = await vectorIndex.rebuildIndex(bare, { limit: 100 });
|
|
145
|
-
assert.deepEqual(result, { indexed: 0, skipped: 0 }, "guard returns early when embedSingle
|
|
145
|
+
assert.deepEqual(result, { indexed: 0, skipped: 0 }, "guard returns early when neither embed nor embedSingle is available");
|
|
146
146
|
assert.equal(vectorIndex.modelHash(), undefined, "no model marked");
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
test("rebuildIndex supports an embed-only OpenAI-compatible embedder (issue #10)", async () => {
|
|
150
|
+
const { store, vectorIndex } = setup();
|
|
151
|
+
addMemory(store, { title: "缺向量", content: "A" });
|
|
152
|
+
const embedOnly = {
|
|
153
|
+
embed: async (text) => [1, 0, 0], // OpenAI-compatible single-text embed
|
|
154
|
+
modelHash: "text-embedding#abc",
|
|
155
|
+
dimension: 3
|
|
156
|
+
};
|
|
157
|
+
const result = await vectorIndex.rebuildIndex(embedOnly, { limit: 100 });
|
|
158
|
+
assert.equal(result.indexed, 1, "embed-only embedder indexes the missing row");
|
|
159
|
+
assert.equal(result.skipped, 0);
|
|
160
|
+
assert.equal(vectorIndex.modelHash(), "text-embedding#abc", "model fingerprint recorded");
|
|
161
|
+
assert.equal(vectorIndex.dimension(), 3, "dimension recorded");
|
|
162
|
+
assert.equal(vectorIndex.getStats().embeddedCount, 1, "embedding persisted");
|
|
163
|
+
});
|
|
164
|
+
|
|
149
165
|
test("rebuildIndex works with embedSingle-only embedder", async () => {
|
|
150
166
|
const { store, vectorIndex } = setup();
|
|
151
167
|
addMemory(store, { title: "缺向量", content: "A" });
|