@modusensus/dsh-mneme 0.5.0 → 0.5.2
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 +419 -397
- package/lib/api.js +516 -516
- package/lib/commands.js +64 -64
- package/lib/config.js +252 -252
- package/lib/dream.js +817 -817
- package/lib/embedding.js +154 -154
- package/lib/hot-memory.js +7 -0
- package/lib/index.js +341 -341
- package/lib/inject.js +208 -208
- package/lib/local-embedder.js +282 -282
- package/lib/reranker.js +218 -215
- package/lib/service.js +1489 -1484
- package/lib/store.js +6 -2
- package/lib/summarize.js +5 -1
- package/lib/tools.js +7 -2
- package/package.json +1 -1
- package/scripts/sync-lib.js +7 -2
- package/src/hot-memory.js +53 -46
- package/src/reranker.js +218 -215
- package/src/service.js +1489 -1484
- package/src/store.js +6 -2
- package/src/summarize.js +5 -1
- package/src/tools.js +7 -2
- package/test/hot-memory.test.js +174 -145
- package/test/provenance.test.js +103 -0
- package/test/reranker.test.js +240 -197
- package/test/service-search.test.js +199 -174
package/src/store.js
CHANGED
|
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS memories (
|
|
|
12
12
|
forgotten INTEGER NOT NULL DEFAULT 0,
|
|
13
13
|
archived INTEGER NOT NULL DEFAULT 0,
|
|
14
14
|
source TEXT,
|
|
15
|
+
session_id TEXT,
|
|
15
16
|
content_history TEXT,
|
|
16
17
|
embedding TEXT,
|
|
17
18
|
epistemic_status TEXT NOT NULL DEFAULT 'subjective',
|
|
@@ -321,6 +322,7 @@ function toRow(row) {
|
|
|
321
322
|
forgotten: row.forgotten === 1,
|
|
322
323
|
archived: row.archived === 1,
|
|
323
324
|
source: row.source ?? undefined,
|
|
325
|
+
session_id: row.session_id ?? undefined,
|
|
324
326
|
content_history: parseJsonArray(row.content_history),
|
|
325
327
|
quality_score: row.quality_score !== null && row.quality_score !== undefined ? Number(row.quality_score) : undefined,
|
|
326
328
|
epistemic_status: row.epistemic_status ?? "subjective",
|
|
@@ -571,6 +573,7 @@ export function createStore(path) {
|
|
|
571
573
|
addColumn("memories", "epistemic_status", "ALTER TABLE memories ADD COLUMN epistemic_status TEXT NOT NULL DEFAULT 'subjective'");
|
|
572
574
|
addColumn("memories", "content_history", "ALTER TABLE memories ADD COLUMN content_history TEXT");
|
|
573
575
|
addColumn("memories", "quality_score", "ALTER TABLE memories ADD COLUMN quality_score REAL");
|
|
576
|
+
addColumn("memories", "session_id", "ALTER TABLE memories ADD COLUMN session_id TEXT");
|
|
574
577
|
|
|
575
578
|
// Legacy dream_runs without policy_epoch → backfill with the default epoch.
|
|
576
579
|
addColumn("dream_runs", "policy_epoch", "ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
|
|
@@ -657,8 +660,8 @@ export function createStore(path) {
|
|
|
657
660
|
: inferEpistemicStatus(memory);
|
|
658
661
|
runAtomically(() => {
|
|
659
662
|
db.prepare(
|
|
660
|
-
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, content_history, quality_score, embedding, epistemic_status, created_at, updated_at)
|
|
661
|
-
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
663
|
+
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, session_id, content_history, quality_score, embedding, epistemic_status, created_at, updated_at)
|
|
664
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
662
665
|
).run(
|
|
663
666
|
id,
|
|
664
667
|
type,
|
|
@@ -668,6 +671,7 @@ export function createStore(path) {
|
|
|
668
671
|
importance,
|
|
669
672
|
memory.archived ? 1 : 0,
|
|
670
673
|
memory.source ?? null,
|
|
674
|
+
memory.session_id ?? null,
|
|
671
675
|
JSON.stringify(memory.content_history ?? []),
|
|
672
676
|
Number.isFinite(memory.quality_score) ? memory.quality_score : null,
|
|
673
677
|
embedding,
|
package/src/summarize.js
CHANGED
|
@@ -182,7 +182,11 @@ export function createSummarizer(ctx, service, config) {
|
|
|
182
182
|
.join("");
|
|
183
183
|
const entries = parseSummaryJson(text || assembledText);
|
|
184
184
|
for (const entry of entries) {
|
|
185
|
-
|
|
185
|
+
// Provenance: the summarizer runs on a real session (turn/end hook), so
|
|
186
|
+
// session.id is always available here — it rides both the human-readable
|
|
187
|
+
// source label and the structured session_id column (v0.5.x memory
|
|
188
|
+
// provenance, the raw material for v0.6.0 reasoning-path / drift analysis).
|
|
189
|
+
service.saveWithDedupe({ ...entry, source: `session:${session.id}`, session_id: session.id });
|
|
186
190
|
}
|
|
187
191
|
} finally {
|
|
188
192
|
if (audit) {
|
package/src/tools.js
CHANGED
|
@@ -16,6 +16,7 @@ const MEMORY_ITEM_SCHEMA = {
|
|
|
16
16
|
tags: { type: "array", items: { type: "string" } },
|
|
17
17
|
importance: { type: "integer", required: true },
|
|
18
18
|
source: { type: "string" },
|
|
19
|
+
session_id: { type: "string" },
|
|
19
20
|
created_at: { type: "string", required: true },
|
|
20
21
|
updated_at: { type: "string", required: true }
|
|
21
22
|
}
|
|
@@ -48,14 +49,18 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
48
49
|
},
|
|
49
50
|
render: (_args, value) => TEXT_OUTPUT(`memory ${value.action}: ${value.id}`)
|
|
50
51
|
},
|
|
51
|
-
async execute(args) {
|
|
52
|
+
async execute(args, exec) {
|
|
52
53
|
const { action, memory } = service.saveWithDedupe({
|
|
53
54
|
type: args.type,
|
|
54
55
|
title: args.title,
|
|
55
56
|
content: args.content,
|
|
56
57
|
tags: args.tags ?? [],
|
|
57
58
|
importance: args.importance ?? 3,
|
|
58
|
-
source: args.source ?? "tool"
|
|
59
|
+
source: args.source ?? "tool",
|
|
60
|
+
// Provenance: the session that issued the tool call. exec.agent is
|
|
61
|
+
// set by the agent loop (undefined in unit tests / direct calls) —
|
|
62
|
+
// absent a session, session_id stays null rather than fabricating one.
|
|
63
|
+
session_id: exec?.agent?.session?.id ?? undefined
|
|
59
64
|
});
|
|
60
65
|
return { action, id: memory.id };
|
|
61
66
|
}
|
package/test/hot-memory.test.js
CHANGED
|
@@ -1,145 +1,174 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { createHotMemory, estimateTokens } from "../src/hot-memory.js";
|
|
4
|
-
import { createStore } from "../src/store.js";
|
|
5
|
-
import { createService } from "../src/service.js";
|
|
6
|
-
import { createVectorIndex } from "../src/vector-index.js";
|
|
7
|
-
|
|
8
|
-
// --- hot memory buffer ---
|
|
9
|
-
|
|
10
|
-
test("hot memory keeps the latest rounds within maxRounds", () => {
|
|
11
|
-
const hot = createHotMemory({ maxRounds: 2, maxTokens: 10000 });
|
|
12
|
-
hot.add({ query: "第一轮", response: "答一" });
|
|
13
|
-
hot.add({ query: "第二轮", response: "答二" });
|
|
14
|
-
hot.add({ query: "第三轮", response: "答三" });
|
|
15
|
-
assert.equal(hot.rounds().length, 2);
|
|
16
|
-
assert.ok(hot.getContext().includes("第三轮"));
|
|
17
|
-
assert.ok(!hot.getContext().includes("第一轮"));
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test("hot memory enforces the token budget", () => {
|
|
21
|
-
const hot = createHotMemory({ maxRounds: 10, maxTokens: 30 });
|
|
22
|
-
hot.add({ query: "很长的第一轮问题".repeat(10), response: "很长的回答".repeat(10) });
|
|
23
|
-
hot.add({ query: "第二轮", response: "答二" });
|
|
24
|
-
// The first round alone blows the budget; the newest round survives and
|
|
25
|
-
// the buffer never empties completely.
|
|
26
|
-
const rounds = hot.rounds();
|
|
27
|
-
assert.ok(rounds.length >= 1);
|
|
28
|
-
assert.equal(rounds[rounds.length - 1].query, "第二轮");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("hot memory getContext uses the Q/A round format", () => {
|
|
32
|
-
const hot = createHotMemory({ maxRounds: 5, maxTokens: 10000 });
|
|
33
|
-
hot.add({ query: "Q1", response: "A1" });
|
|
34
|
-
hot.add({ query: "Q2", response: "A2" });
|
|
35
|
-
assert.equal(hot.getContext(), "Q: Q1\nA: A1\n\nQ: Q2\nA: A2");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("hot memory ignores empty rounds and clears", () => {
|
|
39
|
-
const hot = createHotMemory({ maxRounds: 5, maxTokens: 10000 });
|
|
40
|
-
hot.add({ query: "", response: "x" });
|
|
41
|
-
hot.add(null);
|
|
42
|
-
assert.equal(hot.rounds().length, 0);
|
|
43
|
-
hot.add({ query: "q" });
|
|
44
|
-
hot.clear();
|
|
45
|
-
assert.equal(hot.getContext(), "");
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("estimateTokens counts CJK heavier than ASCII", () => {
|
|
49
|
-
assert.ok(estimateTokens("中文内容") > estimateTokens("abcd"));
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
// ---
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const { store,
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
store
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
store
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
assert.ok(
|
|
145
|
-
});
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createHotMemory, estimateTokens } from "../src/hot-memory.js";
|
|
4
|
+
import { createStore } from "../src/store.js";
|
|
5
|
+
import { createService } from "../src/service.js";
|
|
6
|
+
import { createVectorIndex } from "../src/vector-index.js";
|
|
7
|
+
|
|
8
|
+
// --- hot memory buffer ---
|
|
9
|
+
|
|
10
|
+
test("hot memory keeps the latest rounds within maxRounds", () => {
|
|
11
|
+
const hot = createHotMemory({ maxRounds: 2, maxTokens: 10000 });
|
|
12
|
+
hot.add({ query: "第一轮", response: "答一" });
|
|
13
|
+
hot.add({ query: "第二轮", response: "答二" });
|
|
14
|
+
hot.add({ query: "第三轮", response: "答三" });
|
|
15
|
+
assert.equal(hot.rounds().length, 2);
|
|
16
|
+
assert.ok(hot.getContext().includes("第三轮"));
|
|
17
|
+
assert.ok(!hot.getContext().includes("第一轮"));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("hot memory enforces the token budget", () => {
|
|
21
|
+
const hot = createHotMemory({ maxRounds: 10, maxTokens: 30 });
|
|
22
|
+
hot.add({ query: "很长的第一轮问题".repeat(10), response: "很长的回答".repeat(10) });
|
|
23
|
+
hot.add({ query: "第二轮", response: "答二" });
|
|
24
|
+
// The first round alone blows the budget; the newest round survives and
|
|
25
|
+
// the buffer never empties completely.
|
|
26
|
+
const rounds = hot.rounds();
|
|
27
|
+
assert.ok(rounds.length >= 1);
|
|
28
|
+
assert.equal(rounds[rounds.length - 1].query, "第二轮");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("hot memory getContext uses the Q/A round format", () => {
|
|
32
|
+
const hot = createHotMemory({ maxRounds: 5, maxTokens: 10000 });
|
|
33
|
+
hot.add({ query: "Q1", response: "A1" });
|
|
34
|
+
hot.add({ query: "Q2", response: "A2" });
|
|
35
|
+
assert.equal(hot.getContext(), "Q: Q1\nA: A1\n\nQ: Q2\nA: A2");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("hot memory ignores empty rounds and clears", () => {
|
|
39
|
+
const hot = createHotMemory({ maxRounds: 5, maxTokens: 10000 });
|
|
40
|
+
hot.add({ query: "", response: "x" });
|
|
41
|
+
hot.add(null);
|
|
42
|
+
assert.equal(hot.rounds().length, 0);
|
|
43
|
+
hot.add({ query: "q" });
|
|
44
|
+
hot.clear();
|
|
45
|
+
assert.equal(hot.getContext(), "");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("estimateTokens counts CJK heavier than ASCII", () => {
|
|
49
|
+
assert.ok(estimateTokens("中文内容") > estimateTokens("abcd"));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// --- entry defense: non-positive / non-integer bounds fall back to defaults ---
|
|
53
|
+
// Bug: createHotMemory({ maxRounds: -1 }) made the eviction while-loop
|
|
54
|
+
// `while (buffer.length > maxRounds)` unbounded — after the buffer emptied,
|
|
55
|
+
// `0 > -1` stayed true and buffer.shift() on an empty array is a no-op, so
|
|
56
|
+
// every add() spun forever. Non-integer values (1.5, NaN, null) were also
|
|
57
|
+
// silently wrong. The fix clamps them to the 5/2000 defaults at the door.
|
|
58
|
+
|
|
59
|
+
test("hot memory falls back to maxRounds=5 for non-positive/invalid values", () => {
|
|
60
|
+
for (const bad of [0, -1, 1.5, NaN, null]) {
|
|
61
|
+
const hot = createHotMemory({ maxRounds: bad, maxTokens: 10000 });
|
|
62
|
+
for (let i = 0; i < 8; i++) hot.add({ query: `第${i}轮`, response: "x" });
|
|
63
|
+
assert.equal(hot.rounds().length, 5, `maxRounds=${bad} must fall back to 5, no infinite loop`);
|
|
64
|
+
assert.ok(hot.getContext().includes("第7轮"), `maxRounds=${bad}: newest round survives`);
|
|
65
|
+
assert.ok(!hot.getContext().includes("第0轮"), `maxRounds=${bad}: oldest round evicted`);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("hot memory falls back to maxTokens=2000 for non-positive/infinite values", () => {
|
|
70
|
+
for (const bad of [0, -1, Infinity]) {
|
|
71
|
+
const hot = createHotMemory({ maxRounds: 50, maxTokens: bad });
|
|
72
|
+
// 50 rounds at ~74 tokens each blow a 2000-token budget; the fallback must
|
|
73
|
+
// evict into (1, 50). A broken budget of 0/-1 would squeeze to 1 round and
|
|
74
|
+
// Infinity would keep all 50 — both are the pre-fix behavior.
|
|
75
|
+
for (let i = 0; i < 50; i++) hot.add({ query: `第${i}轮`, response: "长回答".repeat(40) });
|
|
76
|
+
const n = hot.rounds().length;
|
|
77
|
+
assert.ok(n > 1 && n < 50, `maxTokens=${bad} falls back to 2000 (kept ${n} rounds)`);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// --- service-level: BM25 fusion + semantic dedup + selective injection ---
|
|
82
|
+
|
|
83
|
+
function toyVec(text) {
|
|
84
|
+
const v = new Array(64).fill(0);
|
|
85
|
+
for (const t of String(text).toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean)) {
|
|
86
|
+
let h = 0;
|
|
87
|
+
for (const ch of t) h = (h * 31 + ch.codePointAt(0)) >>> 0;
|
|
88
|
+
v[h % 64] += 1;
|
|
89
|
+
}
|
|
90
|
+
const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1;
|
|
91
|
+
return v.map((x) => x / norm);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function setup(overrides = {}) {
|
|
95
|
+
const store = createStore(":memory:");
|
|
96
|
+
const config = {
|
|
97
|
+
entitySearchEnabled: false,
|
|
98
|
+
bm25SearchEnabled: true,
|
|
99
|
+
adaptiveThresholdEnabled: false,
|
|
100
|
+
searchSemanticDedup: true,
|
|
101
|
+
selectiveInjectEnabled: true,
|
|
102
|
+
...overrides
|
|
103
|
+
};
|
|
104
|
+
const service = createService({ store, mirror: null, config, logger: null });
|
|
105
|
+
service.setVectorIndex(createVectorIndex({ store, logger: null }));
|
|
106
|
+
service.setEmbedder({ embedSingle: async (t) => toyVec(t) });
|
|
107
|
+
return { store, service };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
test("searchMemories fuses BM25: scattered-term queries recall both rows", async () => {
|
|
111
|
+
const { store, service } = setup();
|
|
112
|
+
const a = store.save({ type: "project", title: "异步并发模式", content: "async runtime 选用 tokio", importance: 3 });
|
|
113
|
+
const b = store.save({ type: "decision", title: "语言迁移", content: "编译模块迁移到 Rust", importance: 3 });
|
|
114
|
+
store.save({ type: "project", title: "无关", content: "夜间 ETL 脚本", importance: 3 });
|
|
115
|
+
|
|
116
|
+
// "rust 异步" is not a substring of either row — LIKE misses both; BM25
|
|
117
|
+
// must surface both rows in the merged result.
|
|
118
|
+
const results = await service.searchMemories("rust 异步", { mode: "auto", topK: 5 });
|
|
119
|
+
const ids = results.map((r) => r.id);
|
|
120
|
+
assert.ok(ids.includes(a.id), "async row must be recalled via BM25");
|
|
121
|
+
assert.ok(ids.includes(b.id), "rust row must be recalled via BM25");
|
|
122
|
+
assert.equal(results.find((r) => r.id === a.id)?.source, "bm25");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("bm25SearchEnabled=false restores the two-path behavior", async () => {
|
|
126
|
+
const { store, service } = setup({ bm25SearchEnabled: false });
|
|
127
|
+
const a = store.save({ type: "project", title: "异步并发模式", content: "async runtime 选用 tokio", importance: 3 });
|
|
128
|
+
const results = await service.searchMemories("rust 异步", { mode: "auto", topK: 5 });
|
|
129
|
+
assert.ok(!results.some((r) => r.id === a.id), "no BM25 → scattered-term miss is back");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("search-time semantic dedup drops near-identical embeddings", async () => {
|
|
133
|
+
const { store, service } = setup();
|
|
134
|
+
const a = store.save({ type: "project", title: "偏好 A", content: "用户喜欢深色主题编辑器", importance: 3 });
|
|
135
|
+
const b = store.save({ type: "project", title: "偏好 A 备份", content: "用户喜欢深色主题编辑器(备份)", importance: 3 });
|
|
136
|
+
store.setEmbedding(a.id, toyVec("用户喜欢深色主题编辑器"));
|
|
137
|
+
store.setEmbedding(b.id, toyVec("用户喜欢深色主题编辑器"));
|
|
138
|
+
|
|
139
|
+
// auto (not keyword): keyword mode is the documented text-only path and is
|
|
140
|
+
// exempt from dedup by design; auto exercises the dedup the way production
|
|
141
|
+
// searches run.
|
|
142
|
+
const results = await service.searchMemories("深色主题", { mode: "auto", topK: 5 });
|
|
143
|
+
const ids = results.map((r) => r.id);
|
|
144
|
+
assert.ok(ids.includes(a.id) !== ids.includes(b.id), "one of the near-duplicate pair is dropped");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("searchSemanticDedup=false keeps duplicate embeddings", async () => {
|
|
148
|
+
const { store, service } = setup({ searchSemanticDedup: false });
|
|
149
|
+
const a = store.save({ type: "project", title: "偏好 A", content: "用户喜欢深色主题编辑器", importance: 3 });
|
|
150
|
+
const b = store.save({ type: "project", title: "偏好 A 备份", content: "用户喜欢深色主题编辑器(备份)", importance: 3 });
|
|
151
|
+
store.setEmbedding(a.id, toyVec("用户喜欢深色主题编辑器"));
|
|
152
|
+
store.setEmbedding(b.id, toyVec("用户喜欢深色主题编辑器"));
|
|
153
|
+
const results = await service.searchMemories("深色主题", { mode: "keyword", topK: 5 });
|
|
154
|
+
assert.equal(results.length, 2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("selective injection re-orders candidates by query similarity", () => {
|
|
158
|
+
const { store, service } = setup();
|
|
159
|
+
const thesis = store.save({ type: "project", title: "湿地论文", content: "毕业论文研究城市湿地公园", importance: 5 });
|
|
160
|
+
const plugin = store.save({ type: "project", title: "插件项目", content: "dsh-mneme 记忆插件开发", importance: 5 });
|
|
161
|
+
store.setEmbedding(thesis.id, toyVec("毕业论文研究城市湿地公园"));
|
|
162
|
+
store.setEmbedding(plugin.id, toyVec("dsh-mneme 记忆插件开发"));
|
|
163
|
+
|
|
164
|
+
// Rule-based order would put both at equal importance; the query vector is
|
|
165
|
+
// about the thesis, so topic ranking must put the thesis memory first.
|
|
166
|
+
const picked = service.injectCandidates({
|
|
167
|
+
query: "论文写作",
|
|
168
|
+
queryVector: toyVec("毕业论文研究城市湿地公园"),
|
|
169
|
+
maxItems: 2,
|
|
170
|
+
threshold: 3
|
|
171
|
+
});
|
|
172
|
+
assert.equal(picked[0].id, thesis.id);
|
|
173
|
+
assert.ok(picked.some((m) => m.id === plugin.id));
|
|
174
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createStore } from "../src/store.js";
|
|
8
|
+
import { createService } from "../src/service.js";
|
|
9
|
+
import { createTools } from "../src/tools.js";
|
|
10
|
+
|
|
11
|
+
const openMemory = () => createStore(":memory:");
|
|
12
|
+
|
|
13
|
+
// Memory provenance (v0.5.x): every memory born from a session carries its
|
|
14
|
+
// session_id so v0.6.0 reasoning-path / interest-drift analysis can rebuild
|
|
15
|
+
// the derivation chain later. session_id is birth provenance — set once at
|
|
16
|
+
// creation, preserved by merges, never fabricated when no session exists.
|
|
17
|
+
|
|
18
|
+
test("save persists session_id and returns it", () => {
|
|
19
|
+
const store = openMemory();
|
|
20
|
+
const saved = store.save({
|
|
21
|
+
type: "preference",
|
|
22
|
+
title: "A",
|
|
23
|
+
content: "c",
|
|
24
|
+
tags: [],
|
|
25
|
+
importance: 3,
|
|
26
|
+
source: "tool",
|
|
27
|
+
session_id: "session-42"
|
|
28
|
+
});
|
|
29
|
+
assert.equal(saved.session_id, "session-42");
|
|
30
|
+
assert.equal(store.getById(saved.id).session_id, "session-42");
|
|
31
|
+
store.close();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("save without session_id leaves it undefined (never fabricated)", () => {
|
|
35
|
+
const store = openMemory();
|
|
36
|
+
const saved = store.save({ type: "history", title: "B", content: "c" });
|
|
37
|
+
assert.equal(saved.session_id, undefined);
|
|
38
|
+
store.close();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("dedupe merge keeps the ORIGINAL session_id (provenance = birth)", () => {
|
|
42
|
+
const store = openMemory();
|
|
43
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
44
|
+
service.saveWithDedupe({
|
|
45
|
+
type: "preference", title: "语言", content: "中文", session_id: "session-1"
|
|
46
|
+
});
|
|
47
|
+
const merged = service.saveWithDedupe({
|
|
48
|
+
type: "preference", title: "语言", content: "中文 + 英文", session_id: "session-2"
|
|
49
|
+
});
|
|
50
|
+
assert.equal(merged.action, "merged");
|
|
51
|
+
assert.equal(store.getById(merged.memory.id).session_id, "session-1");
|
|
52
|
+
store.close();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("memory_save tool records exec.agent.session.id as session_id", async () => {
|
|
56
|
+
const store = openMemory();
|
|
57
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
58
|
+
const ctx = { tools: { register() { return () => {}; } } };
|
|
59
|
+
const tools = createTools(ctx, service, {}, undefined);
|
|
60
|
+
const saveTool = tools.find((t) => t.name === "memory_save");
|
|
61
|
+
const out = await saveTool.execute(
|
|
62
|
+
{ type: "decision", title: "T", content: "c" },
|
|
63
|
+
{ agent: { session: { id: "session-tool-9" } } }
|
|
64
|
+
);
|
|
65
|
+
assert.equal(store.getById(out.id).session_id, "session-tool-9");
|
|
66
|
+
store.close();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("memory_save tool without agent context leaves session_id null", async () => {
|
|
70
|
+
const store = openMemory();
|
|
71
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
72
|
+
const ctx = { tools: { register() { return () => {}; } } };
|
|
73
|
+
const tools = createTools(ctx, service, {}, undefined);
|
|
74
|
+
const saveTool = tools.find((t) => t.name === "memory_save");
|
|
75
|
+
const out = await saveTool.execute({ type: "decision", title: "T2", content: "c" }, {});
|
|
76
|
+
assert.equal(store.getById(out.id).session_id, undefined);
|
|
77
|
+
store.close();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("legacy DB without session_id column migrates and saves with provenance", () => {
|
|
81
|
+
const dir = mkdtempSync(join(tmpdir(), "mneme-prov-"));
|
|
82
|
+
const dbPath = join(dir, "legacy.db");
|
|
83
|
+
const db = new DatabaseSync(dbPath);
|
|
84
|
+
db.exec(`CREATE TABLE memories (
|
|
85
|
+
id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL,
|
|
86
|
+
tags TEXT NOT NULL DEFAULT '[]', importance INTEGER NOT NULL DEFAULT 3,
|
|
87
|
+
forgotten INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0,
|
|
88
|
+
source TEXT, content_history TEXT, embedding TEXT,
|
|
89
|
+
epistemic_status TEXT NOT NULL DEFAULT 'subjective',
|
|
90
|
+
last_accessed_at TEXT, _full_content TEXT,
|
|
91
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
92
|
+
);`);
|
|
93
|
+
db.close();
|
|
94
|
+
const store = createStore(dbPath);
|
|
95
|
+
const cols = store.db.prepare("PRAGMA table_info(memories)").all().map((c) => c.name);
|
|
96
|
+
assert.ok(cols.includes("session_id"), "session_id column added by migration");
|
|
97
|
+
const saved = store.save({
|
|
98
|
+
type: "history", title: "旧库", content: "迁移后仍可溯源", session_id: "session-legacy"
|
|
99
|
+
});
|
|
100
|
+
assert.equal(store.getById(saved.id).session_id, "session-legacy");
|
|
101
|
+
store.close();
|
|
102
|
+
rmSync(dir, { recursive: true, force: true });
|
|
103
|
+
});
|