@modusensus/dsh-mneme 0.5.2 → 0.5.3
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 -419
- package/lib/client.js +1302 -1302
- package/lib/config.js +10 -6
- package/lib/dream.js +94 -2
- package/lib/hot-memory.js +53 -53
- package/lib/reranker.js +218 -218
- package/lib/service.js +1489 -1489
- package/package.json +1 -1
- package/src/config.js +10 -6
- package/src/dream.js +94 -2
- package/src/hot-memory.js +53 -53
- package/src/reranker.js +218 -218
- package/src/service.js +1489 -1489
- package/test/hot-memory.test.js +174 -174
- package/test/normalize-decisions.test.js +120 -0
- package/test/reasoning-effort.test.js +27 -0
- package/test/reranker.test.js +240 -240
- package/test/service-search.test.js +199 -199
package/lib/config.js
CHANGED
|
@@ -19,11 +19,13 @@ export const Config = z.object({
|
|
|
19
19
|
dreamModel: z.string(),
|
|
20
20
|
dreamMaxTokens: z.natural().min(256).max(131072).default(8192),
|
|
21
21
|
// Pass-through reasoning effort for dream's LLM calls. 'none' (default)
|
|
22
|
-
// omits the field so the provider's own default applies;
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// empty body ("no json array in llm output")
|
|
22
|
+
// omits the field so the provider's own default applies; 'off' explicitly
|
|
23
|
+
// disables thinking — REQUIRED for thinking-type models (deepseek-v4-flash
|
|
24
|
+
// etc.) that would otherwise drain the whole token budget into reasoning and
|
|
25
|
+
// return an empty body ("no json array in llm output"); low/medium/high are
|
|
26
|
+
// forwarded verbatim to cap reasoning spend.
|
|
26
27
|
dreamReasoningEffort: z.union([
|
|
28
|
+
z.const("off"),
|
|
27
29
|
z.const("low"),
|
|
28
30
|
z.const("medium"),
|
|
29
31
|
z.const("high"),
|
|
@@ -197,9 +199,11 @@ export const Config = z.object({
|
|
|
197
199
|
sleepProvider: z.string().default(""),
|
|
198
200
|
sleepModel: z.string().default(""),
|
|
199
201
|
// Pass-through reasoning effort for sleep's LLM passes, same semantics as
|
|
200
|
-
// dreamReasoningEffort: 'none' (default) omits the field;
|
|
201
|
-
//
|
|
202
|
+
// dreamReasoningEffort: 'none' (default) omits the field; 'off' explicitly
|
|
203
|
+
// disables thinking (thinking-type models would burn the whole budget on
|
|
204
|
+
// reasoning); low/medium/high are forwarded verbatim.
|
|
202
205
|
sleepReasoningEffort: z.union([
|
|
206
|
+
z.const("off"),
|
|
203
207
|
z.const("low"),
|
|
204
208
|
z.const("medium"),
|
|
205
209
|
z.const("high"),
|
package/lib/dream.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { validateDecisions, applyDecisions } from "./dream/decisions.js";
|
|
2
2
|
import { clusterMemories, findPotentialConflicts } from "./dream/clustering.js";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
export { validateDecisions, applyDecisions };
|
|
4
|
+
export { validateDecisions, applyDecisions, normalizeDecisions };
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
// Extract the first JSON array from LLM output, tolerating markdown fences,
|
|
@@ -40,6 +40,95 @@ function extractJsonArray(text) {
|
|
|
40
40
|
}
|
|
41
41
|
return null;
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Field-name drift guard (v0.5.3): thinking-type models (deepseek-v4-flash
|
|
46
|
+
* etc.) occasionally ignore the prompt's exact decision schema and emit alias
|
|
47
|
+
* keys —实测方案 A 输出 "consolidation"/"target_ids"、方案 B 输出
|
|
48
|
+
* "action"/"targetIds",都不是插件要求的 "action"/"ids"。normalizeDecisions
|
|
49
|
+
* 在 validateDecisions 之前把常见变体重写回规范字段名,让"字段名不听话但
|
|
50
|
+
* 语义正确"的输出仍可被应用,而不是整单被拒。覆盖三类漂移:
|
|
51
|
+
* 1. 整个 body 是包在对象里的数组({consolidation:[...]} /
|
|
52
|
+
* {decisions:[...]} / {actions:[...]});
|
|
53
|
+
* 2. 决策对象用了别名键(target_ids→ids、keep_source→keepSource、
|
|
54
|
+
* winner_id→winner、targetIds→ids 等);
|
|
55
|
+
* 3. action 值用了同义词(archived→archive、consolidation→merge 等)。
|
|
56
|
+
* 无归一化必要时原样返回(null→null,调用方"no json array"分支不受影响)。
|
|
57
|
+
*/
|
|
58
|
+
const FIELD_ALIASES = {
|
|
59
|
+
// "consolidation" 也可作 action 键(实测 deepseek-v4-flash 这么写过);
|
|
60
|
+
// 但 normalizeOne 对 action 键做字符串过滤,{consolidation:[...]} 这种
|
|
61
|
+
// wrapper 数组不会被误当成 action(顶层 WRAPPER_KEYS 负责解包)。
|
|
62
|
+
action: ["action", "decision", "operation", "op", "action_type", "actionType", "mode", "consolidation"],
|
|
63
|
+
// 注意:action 别名不含 "type"——create 决策的 type 是合法的记忆类型字段,
|
|
64
|
+
// 不能误当 action。
|
|
65
|
+
ids: ["ids", "target_ids", "targetIds", "targets", "memory_ids", "memoryIds", "id_list", "idList", "memories"],
|
|
66
|
+
reason: ["reason", "rationale", "why", "comment", "note", "explanation"],
|
|
67
|
+
importance: ["importance", "priority", "weight", "level", "score"],
|
|
68
|
+
keepSource: ["keepSource", "keep_source", "source_id", "sourceId", "keeper", "keep_id", "keepId"],
|
|
69
|
+
winner: ["winner", "winner_id", "winnerId", "win_id", "winId", "preferred", "primary"],
|
|
70
|
+
loser: ["loser", "loser_id", "loserId", "lose_id", "loseId", "drop_id", "dropId", "archive_id", "archiveId"],
|
|
71
|
+
title: ["title", "new_title", "newTitle", "merged_title", "mergedTitle", "merge_title", "mergeTitle"],
|
|
72
|
+
content: ["content", "new_content", "newContent", "merged_content", "mergedContent", "merge_content", "mergeContent"],
|
|
73
|
+
evidence: ["evidence", "evidence_ids", "evidenceIds"]
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// 保守的 action 值同义词:只收语义无歧义、不可能被误认为记忆类型/其他 action 的映射。
|
|
77
|
+
const ACTION_SYNONYMS = {
|
|
78
|
+
archived: "archive",
|
|
79
|
+
remove: "archive",
|
|
80
|
+
delete: "archive",
|
|
81
|
+
combine: "merge",
|
|
82
|
+
consolidation: "merge",
|
|
83
|
+
consolidate: "merge",
|
|
84
|
+
modify: "update",
|
|
85
|
+
edit: "update"
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const WRAPPER_KEYS = ["consolidation", "decisions", "actions", "results", "updates", "list", "data"];
|
|
89
|
+
|
|
90
|
+
/** 把单个决策对象重写到规范字段名;非对象原样返回。 */
|
|
91
|
+
function normalizeOne(raw) {
|
|
92
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
|
|
93
|
+
const d = {};
|
|
94
|
+
const consumed = new Set();
|
|
95
|
+
for (const [canon, aliases] of Object.entries(FIELD_ALIASES)) {
|
|
96
|
+
for (const key of aliases) {
|
|
97
|
+
if (key in raw && raw[key] !== undefined && raw[key] !== null) {
|
|
98
|
+
// action 必须是字符串——{consolidation:[...]} 的 wrapper 数组跳过。
|
|
99
|
+
if (canon === "action" && Array.isArray(raw[key])) continue;
|
|
100
|
+
d[canon] = raw[key];
|
|
101
|
+
consumed.add(key);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// 保留未被别名消费的原始键(create 的 type、未来字段)原样透传。
|
|
107
|
+
for (const key of Object.keys(raw)) {
|
|
108
|
+
if (!consumed.has(key)) d[key] = raw[key];
|
|
109
|
+
}
|
|
110
|
+
if (typeof d.action === "string") {
|
|
111
|
+
const low = d.action.toLowerCase();
|
|
112
|
+
if (ACTION_SYNONYMS[low] !== undefined) d.action = ACTION_SYNONYMS[low];
|
|
113
|
+
}
|
|
114
|
+
// ids 若被写成单个字符串则包成数组(validateDecisions 要求数组)。
|
|
115
|
+
if (d.ids !== undefined && typeof d.ids === "string") d.ids = [d.ids];
|
|
116
|
+
return d;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 归一化 LLM 决策 body(数组 / 包裹对象 / 单个决策对象),null 原样返回。 */
|
|
120
|
+
function normalizeDecisions(raw) {
|
|
121
|
+
if (!raw) return raw;
|
|
122
|
+
if (Array.isArray(raw)) return raw.map(normalizeOne).filter((d) => d && typeof d === "object");
|
|
123
|
+
if (typeof raw === "object") {
|
|
124
|
+
for (const key of WRAPPER_KEYS) {
|
|
125
|
+
if (Array.isArray(raw[key])) return normalizeDecisions(raw[key]);
|
|
126
|
+
}
|
|
127
|
+
// 单个决策对象 → 包成单元素数组(validateDecisions 要求数组)。
|
|
128
|
+
return [normalizeOne(raw)];
|
|
129
|
+
}
|
|
130
|
+
return raw;
|
|
131
|
+
}
|
|
43
132
|
const SUMMARY_PROMPT = `你是记忆库摘要助手。根据整理后的记忆,生成一段 150-200 字的记忆库总览,覆盖:用户偏好、活跃项目、关键决策。之后作为会话上下文注入。只输出摘要文本,不要其他内容。`;
|
|
44
133
|
|
|
45
134
|
const CONSOLIDATION_PROMPT = `你是记忆库整理助手。下面是全部记忆条目(id、类型、标题、内容、重要性、更新时间)。
|
|
@@ -616,7 +705,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
616
705
|
return finish({ ok: false, error: "llm failed", summary: false });
|
|
617
706
|
}
|
|
618
707
|
|
|
619
|
-
|
|
708
|
+
// v0.5.3 字段名归一化兜底:thinking 模型可能输出别名键/wrapper 对象,
|
|
709
|
+
// 在 validateDecisions 之前重写到规范字段名,语义正确但 schema 不听话的
|
|
710
|
+
// 输出不再整单被拒。
|
|
711
|
+
const decisions = normalizeDecisions(extractJsonArray(decisionText));
|
|
620
712
|
if (!Array.isArray(decisions)) {
|
|
621
713
|
logger?.warn?.(`dsh-mneme dream: no json array in llm output (raw length ${decisionText?.length ?? 0})`);
|
|
622
714
|
return finish({ ok: false, error: "no json array in llm output", summary: false });
|
package/lib/hot-memory.js
CHANGED
|
@@ -1,53 +1,53 @@
|
|
|
1
|
-
// Session-scoped hot memory (v0.5.0 召回率优化 1.3): a short-term buffer of
|
|
2
|
-
// the latest dialogue rounds, kept strictly apart from the long-term memory
|
|
3
|
-
// store. The injector renders it ahead of the long-term recall block so the
|
|
4
|
-
// agent sees "what we were just talking about" without those rounds ever
|
|
5
|
-
// being persisted as memories. Bounded two ways: maxRounds (count) and
|
|
6
|
-
// maxTokens (budget) — whichever evicts first.
|
|
7
|
-
|
|
8
|
-
// CJK-aware token estimate: one Chinese character ≈ 0.6 tokens (clustering
|
|
9
|
-
// behavior of mainstream tokenizers), one ASCII char ≈ 0.25.
|
|
10
|
-
export function estimateTokens(text) {
|
|
11
|
-
const s = String(text ?? "");
|
|
12
|
-
let cjk = 0;
|
|
13
|
-
for (const ch of s) if (ch >= "\u4e00" && ch <= "\u9fff") cjk++;
|
|
14
|
-
return Math.ceil(cjk * 0.6 + (s.length - cjk) * 0.25);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* @param {{maxRounds?: number, maxTokens?: number}} opts
|
|
19
|
-
* @returns {{add(round: {query: string, response?: string}): void,
|
|
20
|
-
* getContext(): string,
|
|
21
|
-
* rounds(): Array, clear(): void}}
|
|
22
|
-
*/
|
|
23
|
-
export function createHotMemory({ maxRounds = 5, maxTokens = 2000 } = {}) {
|
|
24
|
-
// Entry defense: a non-positive or non-integer maxRounds (0, -1, 1.5, NaN,
|
|
25
|
-
// null, "2") would make the eviction while-loop unbounded — the buffer can
|
|
26
|
-
// never shrink below `buffer.length > maxRounds`, so `add` would spin forever.
|
|
27
|
-
// Fall back to the defaults so a hostile/buggy caller can never wedge the
|
|
28
|
-
// hot-memory buffer in an infinite loop.
|
|
29
|
-
maxRounds = (Number.isInteger(maxRounds) && maxRounds > 0) ? maxRounds : 5;
|
|
30
|
-
maxTokens = (Number.isFinite(maxTokens) && maxTokens > 0) ? maxTokens : 2000;
|
|
31
|
-
const buffer = [];
|
|
32
|
-
|
|
33
|
-
function totalTokens() {
|
|
34
|
-
return buffer.reduce(
|
|
35
|
-
(sum, r) => sum + estimateTokens(`Q: ${r.query}\nA: ${r.response ?? ""}`),
|
|
36
|
-
0
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return {
|
|
41
|
-
add(round) {
|
|
42
|
-
if (!round?.query) return;
|
|
43
|
-
buffer.push({ query: String(round.query), response: String(round.response ?? "") });
|
|
44
|
-
while (buffer.length > maxRounds) buffer.shift();
|
|
45
|
-
while (buffer.length > 1 && totalTokens() > maxTokens) buffer.shift();
|
|
46
|
-
},
|
|
47
|
-
getContext() {
|
|
48
|
-
return buffer.map((r) => `Q: ${r.query}\nA: ${r.response ?? ""}`).join("\n\n");
|
|
49
|
-
},
|
|
50
|
-
rounds: () => [...buffer],
|
|
51
|
-
clear() { buffer.length = 0; }
|
|
52
|
-
};
|
|
53
|
-
}
|
|
1
|
+
// Session-scoped hot memory (v0.5.0 召回率优化 1.3): a short-term buffer of
|
|
2
|
+
// the latest dialogue rounds, kept strictly apart from the long-term memory
|
|
3
|
+
// store. The injector renders it ahead of the long-term recall block so the
|
|
4
|
+
// agent sees "what we were just talking about" without those rounds ever
|
|
5
|
+
// being persisted as memories. Bounded two ways: maxRounds (count) and
|
|
6
|
+
// maxTokens (budget) — whichever evicts first.
|
|
7
|
+
|
|
8
|
+
// CJK-aware token estimate: one Chinese character ≈ 0.6 tokens (clustering
|
|
9
|
+
// behavior of mainstream tokenizers), one ASCII char ≈ 0.25.
|
|
10
|
+
export function estimateTokens(text) {
|
|
11
|
+
const s = String(text ?? "");
|
|
12
|
+
let cjk = 0;
|
|
13
|
+
for (const ch of s) if (ch >= "\u4e00" && ch <= "\u9fff") cjk++;
|
|
14
|
+
return Math.ceil(cjk * 0.6 + (s.length - cjk) * 0.25);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {{maxRounds?: number, maxTokens?: number}} opts
|
|
19
|
+
* @returns {{add(round: {query: string, response?: string}): void,
|
|
20
|
+
* getContext(): string,
|
|
21
|
+
* rounds(): Array, clear(): void}}
|
|
22
|
+
*/
|
|
23
|
+
export function createHotMemory({ maxRounds = 5, maxTokens = 2000 } = {}) {
|
|
24
|
+
// Entry defense: a non-positive or non-integer maxRounds (0, -1, 1.5, NaN,
|
|
25
|
+
// null, "2") would make the eviction while-loop unbounded — the buffer can
|
|
26
|
+
// never shrink below `buffer.length > maxRounds`, so `add` would spin forever.
|
|
27
|
+
// Fall back to the defaults so a hostile/buggy caller can never wedge the
|
|
28
|
+
// hot-memory buffer in an infinite loop.
|
|
29
|
+
maxRounds = (Number.isInteger(maxRounds) && maxRounds > 0) ? maxRounds : 5;
|
|
30
|
+
maxTokens = (Number.isFinite(maxTokens) && maxTokens > 0) ? maxTokens : 2000;
|
|
31
|
+
const buffer = [];
|
|
32
|
+
|
|
33
|
+
function totalTokens() {
|
|
34
|
+
return buffer.reduce(
|
|
35
|
+
(sum, r) => sum + estimateTokens(`Q: ${r.query}\nA: ${r.response ?? ""}`),
|
|
36
|
+
0
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
add(round) {
|
|
42
|
+
if (!round?.query) return;
|
|
43
|
+
buffer.push({ query: String(round.query), response: String(round.response ?? "") });
|
|
44
|
+
while (buffer.length > maxRounds) buffer.shift();
|
|
45
|
+
while (buffer.length > 1 && totalTokens() > maxTokens) buffer.shift();
|
|
46
|
+
},
|
|
47
|
+
getContext() {
|
|
48
|
+
return buffer.map((r) => `Q: ${r.query}\nA: ${r.response ?? ""}`).join("\n\n");
|
|
49
|
+
},
|
|
50
|
+
rounds: () => [...buffer],
|
|
51
|
+
clear() { buffer.length = 0; }
|
|
52
|
+
};
|
|
53
|
+
}
|