@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/lib/inject.js
CHANGED
|
@@ -1,16 +1,88 @@
|
|
|
1
|
+
// Best-effort extraction of the current user's latest message text from the
|
|
2
|
+
// live session, for semantic-first injection (Bug4). The system-prompt
|
|
3
|
+
// interpolator renders synchronously, so this walks the already-materialized
|
|
4
|
+
// session event log (same event shape summarize.js consumes) and returns the
|
|
5
|
+
// most recent human message. Any failure degrades to "" — the injector then
|
|
6
|
+
// falls back to the legacy rule-based pick, never breaking the render.
|
|
7
|
+
function lastUserQuery(ctx) {
|
|
8
|
+
try {
|
|
9
|
+
const events = ctx?.agent?.session?.events;
|
|
10
|
+
if (!Array.isArray(events) || events.length === 0) return "";
|
|
11
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
12
|
+
const event = events[i];
|
|
13
|
+
if (event?.type !== "user/message") continue;
|
|
14
|
+
const kind = event.data?.source?.kind;
|
|
15
|
+
if (kind !== undefined && kind !== "user") continue;
|
|
16
|
+
const parts = event.data?.content;
|
|
17
|
+
if (!Array.isArray(parts) || parts.length === 0) continue;
|
|
18
|
+
return parts
|
|
19
|
+
.map((p) => (typeof p === "string" ? p : p?.text ?? ""))
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.join("\n")
|
|
22
|
+
.slice(0, 500);
|
|
23
|
+
}
|
|
24
|
+
} catch { /* session internals unavailable: degrade to no query */ }
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
|
|
1
28
|
export function createInjector(ctx, service, settings, config) {
|
|
2
29
|
const maxItems = config.maxInjectedItems ?? 5;
|
|
3
30
|
const threshold = config.importanceThreshold ?? 3;
|
|
4
31
|
|
|
32
|
+
// Bug6: bound the injected memory block. Each entry's content is truncated to
|
|
33
|
+
// MAX_CONTENT chars (trailing `…`); the whole block gets a MAX_BLOCK budget
|
|
34
|
+
// and an entry that would exceed it collapses to its title only, so a long
|
|
35
|
+
// memory can never push the injected context past a few thousand chars.
|
|
36
|
+
const MAX_CONTENT = 300;
|
|
37
|
+
const MAX_BLOCK = 1500;
|
|
38
|
+
|
|
5
39
|
function render(candidates) {
|
|
6
40
|
if (!candidates.length) return "";
|
|
7
|
-
const
|
|
41
|
+
const header = "[记忆库] 来自 dsh-mneme 的跨会话记忆(用户偏好与高优先级项目/决策):";
|
|
42
|
+
const lines = [header];
|
|
43
|
+
let budget = MAX_BLOCK - header.length;
|
|
8
44
|
for (const m of candidates) {
|
|
9
|
-
|
|
45
|
+
// Epistemic trust (v0.4.5): when enabled, measured observations are
|
|
46
|
+
// flagged so the agent can weigh them above guesses/opinions.
|
|
47
|
+
const verified = config.trustEpistemicWeighting === true && m.epistemic_status === "observation"
|
|
48
|
+
? "[verified] "
|
|
49
|
+
: "";
|
|
50
|
+
const title = `${m.title}(重要性 ${m.importance})`;
|
|
51
|
+
let content = String(m.content ?? "");
|
|
52
|
+
if (content.length > MAX_CONTENT) content = `${content.slice(0, MAX_CONTENT)}…`;
|
|
53
|
+
const full = `- [${m.type}] ${verified}${title}:${content}`;
|
|
54
|
+
if (budget - full.length >= 0) {
|
|
55
|
+
lines.push(full);
|
|
56
|
+
budget -= full.length;
|
|
57
|
+
} else {
|
|
58
|
+
lines.push(`- [${m.type}] ${verified}${title}`);
|
|
59
|
+
}
|
|
10
60
|
}
|
|
11
61
|
return lines.join("\n");
|
|
12
62
|
}
|
|
13
63
|
|
|
64
|
+
// Bug4: the system-prompt render is synchronous, so the semantic query vector
|
|
65
|
+
// must be prefetched asynchronously and cached for the next assembly. The
|
|
66
|
+
// first render after a new user message may still fall back to the rule-based
|
|
67
|
+
// pick; later assemblies in the same session reuse the cached vector. Bounded
|
|
68
|
+
// cache (cap 8, drop oldest) so a long session never grows it unbounded.
|
|
69
|
+
const QUERY_VECTOR_CACHE_MAX = 8;
|
|
70
|
+
const queryVectorCache = new Map();
|
|
71
|
+
let lastPrefetched = "";
|
|
72
|
+
|
|
73
|
+
function prefetchQueryVector(query) {
|
|
74
|
+
if (!query || query === lastPrefetched || queryVectorCache.has(query)) return;
|
|
75
|
+
lastPrefetched = query;
|
|
76
|
+
service.embedQuery(query).then((vec) => {
|
|
77
|
+
if (Array.isArray(vec) && vec.length) {
|
|
78
|
+
queryVectorCache.set(query, vec);
|
|
79
|
+
if (queryVectorCache.size > QUERY_VECTOR_CACHE_MAX) {
|
|
80
|
+
queryVectorCache.delete(queryVectorCache.keys().next().value);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}).catch(() => { /* prefetch is best-effort */ });
|
|
84
|
+
}
|
|
85
|
+
|
|
14
86
|
// User profile + rules: injected ahead of the memory block because they are
|
|
15
87
|
// always-relevant instructions the agent should follow every turn.
|
|
16
88
|
function renderUserSettings() {
|
|
@@ -27,8 +99,15 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
27
99
|
ctx.systemPrompt.context({
|
|
28
100
|
name: "memory",
|
|
29
101
|
order: 90,
|
|
30
|
-
text: () => {
|
|
31
|
-
|
|
102
|
+
text: (ctx) => {
|
|
103
|
+
// Bug4: pass the latest user query so injection prefers semantically
|
|
104
|
+
// relevant memories; lastUserQuery is best-effort (empty → legacy).
|
|
105
|
+
// The query vector is prefetched asynchronously (cached) because the
|
|
106
|
+
// render itself must stay synchronous.
|
|
107
|
+
const query = lastUserQuery(ctx);
|
|
108
|
+
if (query) prefetchQueryVector(query);
|
|
109
|
+
const queryVector = queryVectorCache.get(query);
|
|
110
|
+
const candidates = service.injectCandidates({ query, queryVector, maxItems, threshold });
|
|
32
111
|
return render(candidates);
|
|
33
112
|
}
|
|
34
113
|
}),
|
|
@@ -40,6 +119,7 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
40
119
|
];
|
|
41
120
|
|
|
42
121
|
return () => {
|
|
122
|
+
queryVectorCache.clear();
|
|
43
123
|
for (const dispose of disposers) {
|
|
44
124
|
if (typeof dispose === "function") dispose();
|
|
45
125
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Rule-based memory quality filter (Bug7). Pure + total: no shared state, no
|
|
2
|
+
// async, no external calls, so it can be unit-tested in isolation and wired
|
|
3
|
+
// into the writer without any I/O or store access.
|
|
4
|
+
//
|
|
5
|
+
// evaluateMemoryQuality scores a memory 0-100 and tags low-value signals. The
|
|
6
|
+
// writer then decides (config.memoryQualityFilter):
|
|
7
|
+
// score >= degradeThreshold (60) → stored normally
|
|
8
|
+
// archiveThreshold (30) <= score < 60 → quality_score persisted; the
|
|
9
|
+
// injection sort re-ranks by importance * quality_score/100 (degraded)
|
|
10
|
+
// score < archiveThreshold (30) → archived + tagged low_quality (still
|
|
11
|
+
// recallable via explicit search, just never auto-injected)
|
|
12
|
+
//
|
|
13
|
+
// Signals and their deductions from the base 100:
|
|
14
|
+
// meta meta-memory vocabulary (the memory talks about the
|
|
15
|
+
// memory system itself, not the user's world) −45
|
|
16
|
+
// self_referential title/content mentions its own type label −15
|
|
17
|
+
// short_content content shorter than minContentLength −80
|
|
18
|
+
// repetitive dedup ratio (unique chars / total) < 0.3 −50
|
|
19
|
+
// duplicate bigram similarity to a recent memory > 0.85 −80
|
|
20
|
+
//
|
|
21
|
+
// The meta signal alone lands a well-formed memory in the degraded band
|
|
22
|
+
// (30..60) — it is still stored and searchable, just demoted in injection.
|
|
23
|
+
// Reaching the archive band (< 30) needs a degenerate body (short, repetitive
|
|
24
|
+
// or near-duplicated) or stacked signals.
|
|
25
|
+
|
|
26
|
+
export const META_MEMORY_RE =
|
|
27
|
+
/记忆|mneme|recall|inject|上下文|token|prompt|系统指令|作为AI|作为助手|我需要记住|总结一下刚才/;
|
|
28
|
+
|
|
29
|
+
// Own-type labels, used for self-reference detection (the English type value
|
|
30
|
+
// the AI writers emit plus the Chinese equivalent a human would type).
|
|
31
|
+
const TYPE_LABELS = {
|
|
32
|
+
preference: ["preference", "偏好"],
|
|
33
|
+
project: ["project", "项目"],
|
|
34
|
+
decision: ["decision", "决策", "决定"],
|
|
35
|
+
history: ["history", "历史", "事件"],
|
|
36
|
+
summary: ["summary", "总结", "摘要", "总览"],
|
|
37
|
+
pattern: ["pattern", "模式", "规律"]
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Normalized bigram-overlap similarity in [0,1]; 0 for tiny/empty inputs. */
|
|
41
|
+
export function textSimilarity(a, b) {
|
|
42
|
+
const bigrams = (s) => {
|
|
43
|
+
const set = new Set();
|
|
44
|
+
const t = String(s).replace(/\s+/g, "");
|
|
45
|
+
for (let i = 0; i < t.length - 1; i++) set.add(t.slice(i, i + 2));
|
|
46
|
+
return set;
|
|
47
|
+
};
|
|
48
|
+
const A = bigrams(a);
|
|
49
|
+
const B = bigrams(b);
|
|
50
|
+
if (!A.size || !B.size) return 0;
|
|
51
|
+
let inter = 0;
|
|
52
|
+
for (const g of A) if (B.has(g)) inter++;
|
|
53
|
+
return inter / Math.min(A.size, B.size);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Fraction of characters that are unique (dedup ratio in [0,1]). */
|
|
57
|
+
export function dedupRatio(text) {
|
|
58
|
+
const t = String(text);
|
|
59
|
+
if (!t.length) return 0;
|
|
60
|
+
return new Set(t).size / t.length;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Score a memory's quality. `recentContents` (optional) is the list of recent
|
|
65
|
+
* memory contents used for near-duplicate detection; when omitted the duplicate
|
|
66
|
+
* signal is skipped. Never throws: every input is coerced defensively.
|
|
67
|
+
* @param {object} memory { type, title, content }
|
|
68
|
+
* @param {object} [options]
|
|
69
|
+
* @param {number} [options.minContentLength=10]
|
|
70
|
+
* @param {string[]} [options.recentContents] up to ~20 recent contents
|
|
71
|
+
* @returns {{score: number, tags: string[], reason: string}}
|
|
72
|
+
*/
|
|
73
|
+
export function evaluateMemoryQuality(memory, options = {}) {
|
|
74
|
+
const minContentLength = options.minContentLength ?? 10;
|
|
75
|
+
const recentContents = Array.isArray(options.recentContents) ? options.recentContents : [];
|
|
76
|
+
const title = String(memory?.title ?? "");
|
|
77
|
+
const content = String(memory?.content ?? "");
|
|
78
|
+
const text = `${title}\n${content}`;
|
|
79
|
+
const trimmed = content.trim();
|
|
80
|
+
const tags = [];
|
|
81
|
+
const reasons = [];
|
|
82
|
+
let score = 100;
|
|
83
|
+
|
|
84
|
+
if (META_MEMORY_RE.test(text)) {
|
|
85
|
+
score -= 45;
|
|
86
|
+
tags.push("meta");
|
|
87
|
+
reasons.push("meta-memory vocabulary");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const labels = TYPE_LABELS[memory?.type];
|
|
91
|
+
if (labels && labels.some((l) => text.includes(l))) {
|
|
92
|
+
score -= 15;
|
|
93
|
+
tags.push("self_referential");
|
|
94
|
+
reasons.push("mentions its own type");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (minContentLength > 0 && trimmed.length < minContentLength) {
|
|
98
|
+
score -= 80;
|
|
99
|
+
tags.push("short_content");
|
|
100
|
+
reasons.push(`content shorter than ${minContentLength} chars`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (trimmed.length > 0 && dedupRatio(trimmed) < 0.3) {
|
|
104
|
+
score -= 50;
|
|
105
|
+
tags.push("repetitive");
|
|
106
|
+
reasons.push("repetitive content");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (recentContents.length > 0 && trimmed.length > 0) {
|
|
110
|
+
for (const other of recentContents) {
|
|
111
|
+
if (textSimilarity(trimmed, other) > 0.85) {
|
|
112
|
+
score -= 80;
|
|
113
|
+
tags.push("duplicate");
|
|
114
|
+
reasons.push("near-duplicate of a recent memory");
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
score = Math.max(0, Math.min(100, Math.round(score)));
|
|
121
|
+
if (score < 30) tags.push("low_quality");
|
|
122
|
+
return { score, tags: [...new Set(tags)], reason: reasons.length ? reasons.join("; ") : "ok" };
|
|
123
|
+
}
|