@modusensus/dsh-mneme 0.7.9 → 0.7.11
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.en.md +368 -0
- package/README.md +298 -167
- package/{dsh-mneme/cordis.patch.yml → cordis.patch.yml} +1 -0
- package/{dsh-mneme/lib → lib}/api.js +58 -284
- package/lib/client.js +2086 -0
- package/{dsh-mneme/src → lib}/config.js +6 -100
- package/{dsh-mneme/lib → lib}/dream/decisions.js +94 -143
- package/{dsh-mneme/src → lib}/dream/sleep.js +12 -152
- package/{dsh-mneme/lib → lib}/dream.js +22 -168
- package/{dsh-mneme/src → lib}/hot-memory.js +0 -7
- package/{dsh-mneme/src → lib}/index.js +6 -34
- package/{dsh-mneme/lib → lib}/inject.js +11 -53
- package/{dsh-mneme/src → lib}/mirror.js +1 -12
- package/{dsh-mneme/lib → lib}/quality-filter.js +1 -3
- package/{dsh-mneme/lib → lib}/reranker.js +1 -4
- package/{dsh-mneme/lib → lib}/service.js +20 -410
- package/{dsh-mneme/lib → lib}/settings.js +0 -70
- package/{dsh-mneme/lib → lib}/store.js +35 -450
- package/{dsh-mneme/lib → lib}/summarize.js +10 -10
- package/lib/tools.js +274 -0
- package/package.json +41 -19
- package/{dsh-mneme/scripts → scripts}/e2e-dsh.js +3 -3
- package/{dsh-mneme/scripts → scripts}/sync-lib.js +5 -10
- package/{dsh-mneme/src → src}/api.js +58 -284
- package/{dsh-mneme/lib → src}/config.js +6 -100
- package/{dsh-mneme/src → src}/dream/decisions.js +94 -143
- package/{dsh-mneme/lib → src}/dream/sleep.js +12 -152
- package/{dsh-mneme/src → src}/dream.js +22 -168
- package/{dsh-mneme/lib → src}/hot-memory.js +0 -7
- package/{dsh-mneme/lib → src}/index.js +6 -34
- package/{dsh-mneme/src → src}/inject.js +11 -53
- package/{dsh-mneme/lib → src}/mirror.js +1 -12
- package/{dsh-mneme/src → src}/quality-filter.js +1 -3
- package/{dsh-mneme/src → src}/reranker.js +1 -4
- package/{dsh-mneme/src → src}/service.js +20 -410
- package/{dsh-mneme/src → src}/settings.js +0 -70
- package/{dsh-mneme/src → src}/store.js +35 -450
- package/{dsh-mneme/src → src}/summarize.js +10 -10
- package/src/tools.js +274 -0
- package/{dsh-mneme/test → test}/api.test.js +1 -156
- package/{dsh-mneme/test → test}/client.test.js +22 -207
- package/{dsh-mneme/test → test}/config.test.js +0 -21
- package/{dsh-mneme/test → test}/dream.test.js +1 -160
- package/{dsh-mneme/test → test}/graph-api.test.js +0 -34
- package/{dsh-mneme/test → test}/hot-memory.test.js +0 -29
- package/test/inject.test.js +120 -0
- package/{dsh-mneme/test → test}/llm-audit.test.js +4 -4
- package/{dsh-mneme/test → test}/reasoning-effort.test.js +0 -27
- package/{dsh-mneme/test → test}/recall-layer.test.js +5 -26
- package/{dsh-mneme/test → test}/reranker.test.js +0 -43
- package/{dsh-mneme/test → test}/service-search.test.js +0 -25
- package/{dsh-mneme/test → test}/service.test.js +0 -106
- package/{dsh-mneme/test → test}/settings.test.js +0 -43
- package/{dsh-mneme/test → test}/sleep.test.js +4 -171
- package/{dsh-mneme/test → test}/store.test.js +0 -76
- package/{dsh-mneme/test → test}/summarize.test.js +16 -15
- package/test/tools.test.js +265 -0
- package/.github/workflows/publish.yml +0 -58
- package/.github/workflows/test.yml +0 -32
- package/CHANGELOG.md +0 -91
- package/CONTRIBUTING.md +0 -291
- package/SECURITY.md +0 -718
- package/docs/devlog/2026-08-14-dsh-mneme-dev-log.md +0 -247
- package/docs/devlog/2026-08-15-dsh-mneme-audit-stress-dev-log.md +0 -145
- package/docs/devlog/2026-08-15-dsh-mneme-pipeline-dev-log.md +0 -56
- package/docs/devlog/2026-08-15-dsh-mneme-reflection-dev-log.md +0 -77
- package/docs/devlog/2026-08-15-dsh-mneme-review-fixes-dev-log.md +0 -64
- package/docs/devlog/2026-08-15-dsh-mneme-semantic-dev-log.md +0 -90
- package/dsh-mneme/CHANGELOG.md +0 -406
- package/dsh-mneme/LICENSE +0 -21
- package/dsh-mneme/README.md +0 -498
- package/dsh-mneme/docs/AGENT_MEMORY_RESEARCH.md +0 -183
- package/dsh-mneme/docs/ENTITIES.md +0 -245
- package/dsh-mneme/docs/LOCAL_MODEL.md +0 -141
- package/dsh-mneme/docs/MIGRATION.md +0 -127
- package/dsh-mneme/docs/SEMANTIC.md +0 -256
- package/dsh-mneme/docs/SLEEP.md +0 -163
- package/dsh-mneme/lib/client.js +0 -2050
- package/dsh-mneme/lib/dream/tag-extractor.js +0 -156
- package/dsh-mneme/lib/heat.js +0 -136
- package/dsh-mneme/lib/parser/tag.js +0 -59
- package/dsh-mneme/lib/parser/wiki-link.js +0 -38
- package/dsh-mneme/lib/search/tag-boost.js +0 -61
- package/dsh-mneme/lib/tools.js +0 -458
- package/dsh-mneme/package-lock.json +0 -1936
- package/dsh-mneme/package.json +0 -83
- package/dsh-mneme/scripts/check-sync.js +0 -42
- package/dsh-mneme/src/client.js +0 -2050
- package/dsh-mneme/src/dream/tag-extractor.js +0 -156
- package/dsh-mneme/src/heat.js +0 -136
- package/dsh-mneme/src/parser/tag.js +0 -59
- package/dsh-mneme/src/parser/wiki-link.js +0 -38
- package/dsh-mneme/src/search/tag-boost.js +0 -61
- package/dsh-mneme/src/tools.js +0 -458
- package/dsh-mneme/test/boundary-v0625.test.js +0 -82
- package/dsh-mneme/test/directory.test.js +0 -134
- package/dsh-mneme/test/heat.test.js +0 -148
- package/dsh-mneme/test/inject.test.js +0 -206
- package/dsh-mneme/test/layered-types-stats.test.js +0 -144
- package/dsh-mneme/test/lib-smoke.test.js +0 -109
- package/dsh-mneme/test/normalize-decisions.test.js +0 -120
- package/dsh-mneme/test/provenance.test.js +0 -103
- package/dsh-mneme/test/recall-runs.test.js +0 -93
- package/dsh-mneme/test/sleep-heat.test.js +0 -112
- package/dsh-mneme/test/tag-boost.test.js +0 -125
- package/dsh-mneme/test/tag.test.js +0 -426
- package/dsh-mneme/test/tools.test.js +0 -674
- package/dsh-mneme/test/updated-at-semantics.test.js +0 -113
- package/dsh-mneme/test/wiki-link.test.js +0 -332
- package//346/250/252/345/271/205.png +0 -0
- /package/{dsh-mneme/lib → lib}/commands.js +0 -0
- /package/{dsh-mneme/lib → lib}/dream/clustering.js +0 -0
- /package/{dsh-mneme/lib → lib}/embedding.js +0 -0
- /package/{dsh-mneme/lib → lib}/entities/extractor.js +0 -0
- /package/{dsh-mneme/lib → lib}/local-embedder.js +0 -0
- /package/{dsh-mneme/lib → lib}/search/adaptive.js +0 -0
- /package/{dsh-mneme/lib → lib}/search/bm25.js +0 -0
- /package/{dsh-mneme/lib → lib}/vector-index.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/benchmark-embed.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/benchmark-recall.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/benchmark-rerank.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/stress-dsh.js +0 -0
- /package/{dsh-mneme/src → src}/commands.js +0 -0
- /package/{dsh-mneme/src → src}/dream/clustering.js +0 -0
- /package/{dsh-mneme/src → src}/embedding.js +0 -0
- /package/{dsh-mneme/src → src}/entities/extractor.js +0 -0
- /package/{dsh-mneme/src → src}/local-embedder.js +0 -0
- /package/{dsh-mneme/src → src}/search/adaptive.js +0 -0
- /package/{dsh-mneme/src → src}/search/bm25.js +0 -0
- /package/{dsh-mneme/src → src}/vector-index.js +0 -0
- /package/{dsh-mneme/test → test}/audit.test.js +0 -0
- /package/{dsh-mneme/test → test}/benchmark.test.js +0 -0
- /package/{dsh-mneme/test → test}/clustering.test.js +0 -0
- /package/{dsh-mneme/test → test}/commands.test.js +0 -0
- /package/{dsh-mneme/test → test}/conflict-freeze.test.js +0 -0
- /package/{dsh-mneme/test → test}/entities.test.js +0 -0
- /package/{dsh-mneme/test → test}/epistemic.test.js +0 -0
- /package/{dsh-mneme/test → test}/fnew-0112.test.js +0 -0
- /package/{dsh-mneme/test → test}/fnew-03.test.js +0 -0
- /package/{dsh-mneme/test → test}/helpers/dream-mock.js +0 -0
- /package/{dsh-mneme/test → test}/local-embedder.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror-dirty.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror-edit-digest.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror-generation.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror.test.js +0 -0
- /package/{dsh-mneme/test → test}/peer-blockers.test.js +0 -0
- /package/{dsh-mneme/test → test}/policy-epoch.test.js +0 -0
- /package/{dsh-mneme/test → test}/quality-filter.test.js +0 -0
- /package/{dsh-mneme/test → test}/recall-evals.test.js +0 -0
- /package/{dsh-mneme/test → test}/receipt-chain.test.js +0 -0
- /package/{dsh-mneme/test → test}/reflection.test.js +0 -0
- /package/{dsh-mneme/test → test}/search-fusion.test.js +0 -0
- /package/{dsh-mneme/test → test}/semantic.test.js +0 -0
- /package/{dsh-mneme/test → test}/stress.test.js +0 -0
- /package/{dsh-mneme/test → test}/vector-index.test.js +0 -0
|
@@ -7,163 +7,120 @@ const EPISTEMIC_PRIORITY = { observation: 3, inferred: 2, subjective: 1 };
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Validate a dream decision list against a snapshot of eligible memories.
|
|
10
|
-
*
|
|
11
|
-
* Issue #26 (skipInvalid): 模型几乎必然为了语义相关性而产出跨类型 merge
|
|
12
|
-
* (type 不同,硬性禁止),这类"单条非法"此前让整批校验 ok:false、整单拒绝
|
|
13
|
-
* (applied=0、白耗一次 LLM 调用)。`options.skipInvalid` 开启后,逐条非法的
|
|
14
|
-
* 决策被跳过(从 decisions 中移除、不 claim 任何 id、记录到 `skipped`),
|
|
15
|
-
* 只有全局性错误(update/create 超量、显式覆盖率不足——
|
|
16
|
-
* 这些是"模型失控/输出被截断"信号)仍整单拒绝,防洗白语义不变。
|
|
17
|
-
* `options.allowCrossTypeMerge`(Issue #26 P1)显式放宽跨类型合并检查——
|
|
18
|
-
* 默认 false 保持现有类型边界,true 时跨类型 merge 被允许(类型边界由
|
|
19
|
-
* 用户自行承担)。
|
|
20
|
-
*
|
|
21
|
-
* @param decisions - LLM-produced decision list. In skipInvalid mode, invalid
|
|
22
|
-
* entries are spliced out in place so the caller's downstream apply/audit
|
|
23
|
-
* operates on the surviving subset only.
|
|
10
|
+
* @param decisions - LLM-produced decision list.
|
|
24
11
|
* @param snapshot - Map<id, memory> of eligible (non-archived, non-summary) entries.
|
|
25
|
-
* @returns {{ok: boolean, errors: string[]
|
|
12
|
+
* @returns {{ok: boolean, errors: string[]}}
|
|
26
13
|
*/
|
|
27
14
|
export function validateDecisions(decisions, snapshot, options = {}) {
|
|
28
15
|
const errors = [];
|
|
29
|
-
const skipped = [];
|
|
30
16
|
const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
|
|
31
17
|
const minAgeHours = options.minAgeHours ?? 24;
|
|
32
|
-
const skipInvalid = options.skipInvalid === true;
|
|
33
18
|
if (!Array.isArray(decisions) || decisions.length === 0) {
|
|
34
19
|
return { ok: false, errors: ["decision list must be a non-empty array"] };
|
|
35
20
|
}
|
|
36
21
|
const claimed = new Set();
|
|
37
|
-
const survivors = [];
|
|
38
22
|
for (const [index, d] of decisions.entries()) {
|
|
39
23
|
const at = `decision[${index}]`;
|
|
40
|
-
const local = [];
|
|
41
|
-
const ids = d && d.action === "conflict" ? [d.winner, d.loser] : (d?.ids ?? []);
|
|
42
24
|
if (!d || typeof d !== "object" || !ACTIONS.has(d.action)) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
// so it skips the claiming loop below; evidence is optional provenance
|
|
52
|
-
// (already filtered to real ids by the caller) and is stored in content.
|
|
53
|
-
if (typeof d.title !== "string" || !d.title.trim()) {
|
|
54
|
-
local.push(`${at}: create needs non-empty title`);
|
|
55
|
-
}
|
|
56
|
-
if (typeof d.content !== "string" || !d.content.trim()) {
|
|
57
|
-
local.push(`${at}: create needs non-empty content`);
|
|
58
|
-
}
|
|
59
|
-
if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
|
|
60
|
-
local.push(`${at}: create importance must be an integer 1-5 when provided`);
|
|
61
|
-
}
|
|
62
|
-
if (typeof d.type !== "string" || !d.type.trim()) {
|
|
63
|
-
local.push(`${at}: create needs non-empty type`);
|
|
64
|
-
}
|
|
65
|
-
} else if (!Array.isArray(d.ids) || d.ids.length === 0) {
|
|
66
|
-
local.push(`${at}: ${d.action} needs non-empty ids`);
|
|
25
|
+
errors.push(`${at}: invalid action ${JSON.stringify(d?.action)}`);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const ids = d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
|
|
29
|
+
if (d.action === "conflict") {
|
|
30
|
+
if (!d.winner || !d.loser || d.winner === d.loser) {
|
|
31
|
+
errors.push(`${at}: conflict needs distinct winner and loser`);
|
|
32
|
+
continue;
|
|
67
33
|
}
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
} else {
|
|
76
|
-
// 必须产生实际变化
|
|
77
|
-
const mem = snapshot.get(d.ids[0]);
|
|
78
|
-
const hasChange = (d.title !== undefined && d.title !== mem?.title)
|
|
79
|
-
|| (d.content !== undefined && d.content !== mem?.content)
|
|
80
|
-
|| (d.importance !== undefined && d.importance !== mem?.importance);
|
|
81
|
-
if (!hasChange) {
|
|
82
|
-
local.push(`${at}: update must change at least one field`);
|
|
83
|
-
}
|
|
84
|
-
// 不能更新 summary
|
|
85
|
-
if (mem?.type === "summary") {
|
|
86
|
-
local.push(`${at}: cannot update summary via update action`);
|
|
87
|
-
}
|
|
88
|
-
// 保护期:新建记忆不可立即被 update(可配置)
|
|
89
|
-
const ageHours = (Date.now() - new Date(mem?.created_at).getTime()) / 3600000;
|
|
90
|
-
if (ageHours < minAgeHours) {
|
|
91
|
-
local.push(`${at}: memory too young (< ${minAgeHours}h)`);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
34
|
+
} else if (d.action === "create") {
|
|
35
|
+
// Mint a fresh memory (sleep pattern discovery). Claims no existing id,
|
|
36
|
+
// so it skips the claiming loop below; evidence is optional provenance
|
|
37
|
+
// (already filtered to real ids by the caller) and is stored in content.
|
|
38
|
+
if (typeof d.title !== "string" || !d.title.trim()) {
|
|
39
|
+
errors.push(`${at}: create needs non-empty title`);
|
|
40
|
+
continue;
|
|
94
41
|
}
|
|
95
|
-
if (d.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const mem = snapshot.get(id);
|
|
99
|
-
if (!mem) {
|
|
100
|
-
local.push(`${at}: unknown id ${JSON.stringify(id)}`);
|
|
101
|
-
} else if (mem.archived || mem.type === "summary") {
|
|
102
|
-
local.push(`${at}: id ${JSON.stringify(id)} is archived or summary (not eligible)`);
|
|
103
|
-
}
|
|
104
|
-
// 单决策内重复 id(同一决策引用同一记忆两次)→ 非法;跨决策重复由
|
|
105
|
-
// 全局 claimed 检测(只含已通过校验的幸存决策的 claim)。
|
|
106
|
-
if (seen.has(id)) {
|
|
107
|
-
local.push(`${at}: duplicate id ${JSON.stringify(id)} within one decision`);
|
|
108
|
-
}
|
|
109
|
-
if (claimed.has(id)) {
|
|
110
|
-
local.push(`${at}: id ${JSON.stringify(id)} claimed by multiple decisions`);
|
|
111
|
-
}
|
|
112
|
-
seen.add(id);
|
|
113
|
-
}
|
|
114
|
-
if (d.action === "merge") {
|
|
115
|
-
if (!d.keepSource || !d.ids.includes(d.keepSource)) {
|
|
116
|
-
local.push(`${at}: merge keepSource must be one of ids`);
|
|
117
|
-
}
|
|
118
|
-
if (typeof d.title !== "string" || !d.title.trim() || typeof d.content !== "string" || !d.content.trim()) {
|
|
119
|
-
local.push(`${at}: merge needs non-empty title and content`);
|
|
120
|
-
}
|
|
121
|
-
if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
|
|
122
|
-
local.push(`${at}: merge importance must be an integer 1-5 when provided`);
|
|
123
|
-
}
|
|
124
|
-
// Merging across types would blur preference/project/decision boundaries
|
|
125
|
-
// in the injected context; the snapshot carries each entry's type.
|
|
126
|
-
// Issue #26 (P1): 默认禁止跨类型合并(类型有语义作用——preference 注入
|
|
127
|
-
// 权重更高、decision/project 注入上下文不同,合并会丢类型边界)。用户显式
|
|
128
|
-
// 开启 allowCrossTypeMerge 后放宽该检查,类型边界由用户自行承担。
|
|
129
|
-
const mergeTypes = new Set(d.ids.map((id) => snapshot.get(id)?.type));
|
|
130
|
-
if (mergeTypes.size > 1 && options.allowCrossTypeMerge !== true) {
|
|
131
|
-
local.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
42
|
+
if (typeof d.content !== "string" || !d.content.trim()) {
|
|
43
|
+
errors.push(`${at}: create needs non-empty content`);
|
|
44
|
+
continue;
|
|
134
45
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
// 写入全局 errors,否则会误触发下方的整单拒绝。
|
|
141
|
-
skipped.push({
|
|
142
|
-
index,
|
|
143
|
-
action: d?.action,
|
|
144
|
-
ids,
|
|
145
|
-
error: local.join("; ")
|
|
146
|
-
});
|
|
147
|
-
} else {
|
|
148
|
-
errors.push(...local);
|
|
46
|
+
if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
|
|
47
|
+
errors.push(`${at}: create importance must be an integer 1-5 when provided`);
|
|
48
|
+
}
|
|
49
|
+
if (typeof d.type !== "string" || !d.type.trim()) {
|
|
50
|
+
errors.push(`${at}: create needs non-empty type`);
|
|
149
51
|
}
|
|
150
52
|
continue;
|
|
53
|
+
} else if (!Array.isArray(d.ids) || d.ids.length === 0) {
|
|
54
|
+
errors.push(`${at}: ${d.action} needs non-empty ids`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// update-specific field validation runs BEFORE claiming ids, so a failing
|
|
58
|
+
// update never pollutes the claimed set (which drives the "every id must
|
|
59
|
+
// appear in a decision" check below).
|
|
60
|
+
if (d.action === "update") {
|
|
61
|
+
// 只能更新单条
|
|
62
|
+
if (!Array.isArray(d.ids) || d.ids.length !== 1) {
|
|
63
|
+
errors.push(`${at}: update must target exactly one id`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// 必须产生实际变化
|
|
67
|
+
const mem = snapshot.get(d.ids[0]);
|
|
68
|
+
const hasChange = (d.title !== undefined && d.title !== mem?.title)
|
|
69
|
+
|| (d.content !== undefined && d.content !== mem?.content)
|
|
70
|
+
|| (d.importance !== undefined && d.importance !== mem?.importance);
|
|
71
|
+
if (!hasChange) {
|
|
72
|
+
errors.push(`${at}: update must change at least one field`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// 不能更新 summary
|
|
76
|
+
if (mem?.type === "summary") {
|
|
77
|
+
errors.push(`${at}: cannot update summary via update action`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// 保护期:新建记忆不可立即被 update(可配置)
|
|
81
|
+
const ageHours = (Date.now() - new Date(mem?.created_at).getTime()) / 3600000;
|
|
82
|
+
if (ageHours < minAgeHours) {
|
|
83
|
+
errors.push(`${at}: memory too young (< ${minAgeHours}h)`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
151
86
|
}
|
|
152
|
-
|
|
153
|
-
|
|
87
|
+
for (const id of ids) {
|
|
88
|
+
const mem = snapshot.get(id);
|
|
89
|
+
if (!mem) {
|
|
90
|
+
errors.push(`${at}: unknown id ${JSON.stringify(id)}`);
|
|
91
|
+
} else if (mem.archived || mem.type === "summary") {
|
|
92
|
+
errors.push(`${at}: id ${JSON.stringify(id)} is archived or summary (not eligible)`);
|
|
93
|
+
}
|
|
94
|
+
if (claimed.has(id)) {
|
|
95
|
+
errors.push(`${at}: id ${JSON.stringify(id)} claimed by multiple decisions`);
|
|
96
|
+
}
|
|
97
|
+
claimed.add(id);
|
|
98
|
+
}
|
|
99
|
+
if (d.action === "merge") {
|
|
100
|
+
if (!d.keepSource || !d.ids.includes(d.keepSource)) {
|
|
101
|
+
errors.push(`${at}: merge keepSource must be one of ids`);
|
|
102
|
+
}
|
|
103
|
+
if (typeof d.title !== "string" || !d.title.trim() || typeof d.content !== "string" || !d.content.trim()) {
|
|
104
|
+
errors.push(`${at}: merge needs non-empty title and content`);
|
|
105
|
+
}
|
|
106
|
+
if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
|
|
107
|
+
errors.push(`${at}: merge importance must be an integer 1-5 when provided`);
|
|
108
|
+
}
|
|
109
|
+
// Merging across types would blur preference/project/decision boundaries
|
|
110
|
+
// in the injected context; the snapshot carries each entry's type.
|
|
111
|
+
const mergeTypes = new Set(d.ids.map((id) => snapshot.get(id)?.type));
|
|
112
|
+
if (mergeTypes.size > 1) {
|
|
113
|
+
errors.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
|
|
114
|
+
}
|
|
154
115
|
}
|
|
155
|
-
survivors.push(d);
|
|
156
116
|
}
|
|
157
|
-
// Cap update churn: too many edits in one cycle signals a runaway model
|
|
158
|
-
|
|
159
|
-
// batch, because a model that floods updates/creates is broken, not just
|
|
160
|
-
// slightly schema-drifting.
|
|
161
|
-
const updateCount = survivors.filter((d) => d.action === "update").length;
|
|
117
|
+
// Cap update churn: too many edits in one cycle signals a runaway model
|
|
118
|
+
const updateCount = decisions.filter((d) => d.action === "update").length;
|
|
162
119
|
if (updateCount > maxUpdatePerRun) {
|
|
163
120
|
errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
|
|
164
121
|
}
|
|
165
122
|
// Cap pattern minting per run (sleepMaxPatternPerRun passes through here).
|
|
166
|
-
const createCount =
|
|
123
|
+
const createCount = decisions.filter((d) => d.action === "create").length;
|
|
167
124
|
const maxCreatePerRun = options.maxCreatePerRun ?? 5;
|
|
168
125
|
if (createCount > maxCreatePerRun) {
|
|
169
126
|
errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
|
|
@@ -180,31 +137,25 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
180
137
|
// snapshot(claimed.size / snapshot.size < dreamMinExplicitCoverage)时整单拒绝,
|
|
181
138
|
// 而不是用 keep 把绝大部分 snapshot 全部"通过"。
|
|
182
139
|
if (errors.length > 0) {
|
|
183
|
-
return { ok: false, errors
|
|
140
|
+
return { ok: false, errors };
|
|
184
141
|
}
|
|
185
142
|
const minCoverage = options.dreamMinExplicitCoverage ?? 0.5;
|
|
186
143
|
if (options.dreamImplicitKeep !== false) {
|
|
187
144
|
const coverage = snapshot.size > 0 ? claimed.size / snapshot.size : 1;
|
|
188
145
|
if (coverage < minCoverage) {
|
|
189
146
|
errors.push(`explicit decision coverage ${Math.round(coverage * 100)}% < minimum ${Math.round(minCoverage * 100)}%`);
|
|
190
|
-
return { ok: false, errors
|
|
147
|
+
return { ok: false, errors };
|
|
191
148
|
}
|
|
192
149
|
for (const id of snapshot.keys()) {
|
|
193
|
-
if (!claimed.has(id))
|
|
150
|
+
if (!claimed.has(id)) decisions.push({ action: "keep", ids: [id] });
|
|
194
151
|
}
|
|
195
152
|
} else {
|
|
196
153
|
for (const id of snapshot.keys()) {
|
|
197
154
|
if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
|
|
198
155
|
}
|
|
199
|
-
if (errors.length > 0) return { ok: false, errors
|
|
200
|
-
}
|
|
201
|
-
// 调用方下游(apply/audit)复用同一 decisions 引用:就地同步为 survivors——
|
|
202
|
-
// 在 skipInvalid 模式下去掉被跳过的非法决策;在隐式 keep 下追加补齐的 keep。
|
|
203
|
-
// 内容一致时(无跳过、无补齐)为 no-op。
|
|
204
|
-
if (survivors.length !== decisions.length) {
|
|
205
|
-
decisions.splice(0, decisions.length, ...survivors);
|
|
156
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
206
157
|
}
|
|
207
|
-
return { ok: true, errors
|
|
158
|
+
return { ok: true, errors };
|
|
208
159
|
}
|
|
209
160
|
|
|
210
161
|
/** Marker thrown when a decision target changed since the run snapshot. */
|
|
@@ -18,8 +18,6 @@ import { randomUUID, createHash } from "node:crypto";
|
|
|
18
18
|
import { validateDecisions, applyDecisions } from "./decisions.js";
|
|
19
19
|
import { findPotentialConflicts } from "./clustering.js";
|
|
20
20
|
import { buildReceipt } from "../dream.js";
|
|
21
|
-
import { computeHeat } from "../heat.js";
|
|
22
|
-
import { extractEntities } from "../entities/extractor.js";
|
|
23
21
|
|
|
24
22
|
const SUMMARY_MAX = 120;
|
|
25
23
|
// Conflict similarity threshold per strictness level (v0.4.0):
|
|
@@ -74,23 +72,16 @@ async function streamText(ctx, options) {
|
|
|
74
72
|
return text;
|
|
75
73
|
}
|
|
76
74
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
// can pin a cheaper model for its bulk passes without disturbing the dream
|
|
81
|
-
// route. The agent-default-last ordering matters: in a standard DSH install
|
|
82
|
-
// agentDefaultModel always resolves, so putting it first would make every
|
|
83
|
-
// config route dead code.
|
|
75
|
+
/** LLM route: agent default model first, then sleepProvider/Model, then the
|
|
76
|
+
* dream route as a shared fallback. Sleep can pin a cheaper model for its
|
|
77
|
+
* bulk passes without disturbing the dream route. */
|
|
84
78
|
function resolveSleepRoute(ctx, config, logger) {
|
|
85
|
-
if (config.sleepProvider && config.sleepModel) return { provider: config.sleepProvider, model: config.sleepModel };
|
|
86
|
-
if (config.dreamProvider && config.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
|
|
87
79
|
try {
|
|
88
80
|
const sel = ctx?.agentDefaultModel?.currentSelection?.();
|
|
89
|
-
if (sel?.provider && sel?.model) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
} catch { /* fall through */ }
|
|
81
|
+
if (sel?.provider && sel?.model) return { provider: sel.provider, model: sel.model };
|
|
82
|
+
} catch { /* fall through to config route */ }
|
|
83
|
+
if (config.sleepProvider && config.sleepModel) return { provider: config.sleepProvider, model: config.sleepModel };
|
|
84
|
+
if (config.dreamProvider && config.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
|
|
94
85
|
logger?.warn?.("dsh-mneme sleep: no llm route available");
|
|
95
86
|
return undefined;
|
|
96
87
|
}
|
|
@@ -117,7 +108,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
|
|
|
117
108
|
}
|
|
118
109
|
const strictness = config.sleepConflictStrictness ?? "normal";
|
|
119
110
|
const threshold = CONFLICT_THRESHOLDS[strictness] ?? CONFLICT_THRESHOLDS.normal;
|
|
120
|
-
const memories = service.all().filter((m) => !m.archived && !m.
|
|
111
|
+
const memories = service.all().filter((m) => !m.archived && !m.forgotten && m.type !== "summary");
|
|
121
112
|
if (memories.length < 2) return { status: "skipped", reason: "too few memories" };
|
|
122
113
|
if (signal?.aborted) return { status: "aborted", reason: "user activity" };
|
|
123
114
|
|
|
@@ -248,22 +239,11 @@ function phaseDemotion(service, config, logger, runId, signal = null) {
|
|
|
248
239
|
const archived = [];
|
|
249
240
|
for (const m of service.all()) {
|
|
250
241
|
if (signal?.aborted) break;
|
|
251
|
-
if (m.archived || m.forgotten
|
|
252
|
-
|
|
253
|
-
// fallback updated_at —— 合并/更新刷的 updated_at 不是访问,防止
|
|
254
|
-
// autoDream 合并动作重置新鲜度时钟而伪装成"刚被召回"。
|
|
255
|
-
const ref = m.last_accessed_at ?? m.created_at;
|
|
242
|
+
if (m.archived || m.forgotten) continue;
|
|
243
|
+
const ref = m.last_accessed_at ?? m.updated_at ?? m.created_at;
|
|
256
244
|
if (!ref) continue;
|
|
257
245
|
const t = new Date(ref).getTime();
|
|
258
246
|
if (Number.isNaN(t)) continue;
|
|
259
|
-
// v0.7.0 热联合判定(待办③):时间窗之外再加两道保护闸——热度低于
|
|
260
|
-
// sleepHeatThreshold 且 importance<5 才允许降级。λ=0 的免疫类型 heat 恒
|
|
261
|
-
// 1.0 天然豁免(preference/pattern/summary 永不因 sleep 降级);importance
|
|
262
|
-
// ≥5 的紧要记忆无论多冷都保留。`冷但重要` 与 `热但低值` 均不满足条件。
|
|
263
|
-
const heat = computeHeat(m, Date.now(), config);
|
|
264
|
-
const heatProtected = heat >= (config.sleepHeatThreshold ?? 0.05);
|
|
265
|
-
const important = (m.importance ?? 0) >= 5;
|
|
266
|
-
if (heatProtected || important) continue;
|
|
267
247
|
if (t < compressCut) {
|
|
268
248
|
service.setArchived(m.id, true);
|
|
269
249
|
archived.push(m.id);
|
|
@@ -293,7 +273,7 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
|
|
|
293
273
|
const limit = config.sleepPatternMinMemories ?? 100;
|
|
294
274
|
const memories = service
|
|
295
275
|
.list({ limit: 200, includeForgotten: false })
|
|
296
|
-
.filter((m) => !m.archived &&
|
|
276
|
+
.filter((m) => !m.archived && m.type !== "summary" && m.type !== "pattern")
|
|
297
277
|
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1))
|
|
298
278
|
.slice(0, limit);
|
|
299
279
|
if (memories.length === 0) return { status: "skipped", reason: "no memories to scan" };
|
|
@@ -362,7 +342,7 @@ function phaseRelations(service, config, logger, runId, signal = null) {
|
|
|
362
342
|
if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
|
|
363
343
|
const orphans = entities.filter((e) => (service.getRelations(e.id) ?? []).length === 0);
|
|
364
344
|
if (orphans.length === 0) return { status: "skipped", reason: "no orphan entities" };
|
|
365
|
-
const memories = service.all().filter((m) => !m.archived && !m.
|
|
345
|
+
const memories = service.all().filter((m) => !m.archived && !m.forgotten);
|
|
366
346
|
const seen = new Set();
|
|
367
347
|
const related = [];
|
|
368
348
|
const MAX_RELATIONS_PER_ORPHAN = 3;
|
|
@@ -397,123 +377,6 @@ function phaseRelations(service, config, logger, runId, signal = null) {
|
|
|
397
377
|
};
|
|
398
378
|
}
|
|
399
379
|
|
|
400
|
-
/**
|
|
401
|
-
* Phase 4.5 — batch entity extraction. The write-path extractor (index.js)
|
|
402
|
-
* only fires when entityExtractionEnabled is on — one LLM call per write, a
|
|
403
|
-
* deliberate cost decision — so default installs never accumulate entities,
|
|
404
|
-
* which is exactly why the ego-graph panel (issue #23) renders blank for users
|
|
405
|
-
* with plenty of memories. This phase backfills the entity graph in bulk:
|
|
406
|
-
* memories that carry no entity attrs yet are passed through extractEntities
|
|
407
|
-
* one at a time (oldest first, capped per run). Write-path stays untouched —
|
|
408
|
-
* this is an additive channel for sleep users. Fail-safe per memory: one bad
|
|
409
|
-
* extract never aborts the phase or the cycle.
|
|
410
|
-
*/
|
|
411
|
-
async function phaseEntityExtraction(ctx, service, config, logger, runId, signal = null) {
|
|
412
|
-
if (config.sleepEntityExtractionEnabled !== true) {
|
|
413
|
-
return { status: "skipped", reason: "disabled" };
|
|
414
|
-
}
|
|
415
|
-
const route = resolveSleepRoute(ctx, config, logger);
|
|
416
|
-
if (!route) return { status: "skipped", reason: "no llm route" };
|
|
417
|
-
const maxPerRun = config.sleepEntityExtractionMaxPerRun ?? 20;
|
|
418
|
-
|
|
419
|
-
// Oldest un-extracted first. The store returns bounded pages via SQL (no
|
|
420
|
-
// O(N) full-table scan); JS pages through offsets and only pays the
|
|
421
|
-
// getAttrsByMemory cross-table check on each page, stopping once it has
|
|
422
|
-
// maxPerRun un-extracted memories or the store is exhausted. Every memory
|
|
423
|
-
// this phase touches gets stamped entity_extracted_at (successful or
|
|
424
|
-
// entity-less extracts alike) so a text with no extractable entities is not
|
|
425
|
-
// re-queued forever; failures are NOT stamped, so a transient LLM hiccup
|
|
426
|
-
// retries next cycle.
|
|
427
|
-
const candidates = [];
|
|
428
|
-
for (let offset = 0; candidates.length < maxPerRun; offset += 100) {
|
|
429
|
-
const page = service.listForEntityExtraction({ limit: 100, offset });
|
|
430
|
-
if (page.length === 0) break;
|
|
431
|
-
candidates.push(...page.filter((m) => (service.getAttrsByMemory?.(m.id) ?? []).length === 0));
|
|
432
|
-
}
|
|
433
|
-
candidates.length = Math.min(candidates.length, maxPerRun);
|
|
434
|
-
if (candidates.length === 0) return { status: "skipped", reason: "no memories to extract" };
|
|
435
|
-
if (signal?.aborted) return { status: "aborted", reason: "user activity" };
|
|
436
|
-
|
|
437
|
-
// extractEntities expects callLLM(messages, options) → text; adapt sleep's
|
|
438
|
-
// streamText + route resolution (same precedence as the other phases).
|
|
439
|
-
const callLLM = async (messages) => {
|
|
440
|
-
const r = resolveSleepRoute(ctx, config, logger);
|
|
441
|
-
if (!r) return undefined;
|
|
442
|
-
return streamText(ctx, {
|
|
443
|
-
...r,
|
|
444
|
-
purpose: "sleep-entity-extract",
|
|
445
|
-
maxTokens: 4096,
|
|
446
|
-
...(config.sleepReasoningEffort && config.sleepReasoningEffort !== "none"
|
|
447
|
-
? { reasoningEffort: config.sleepReasoningEffort }
|
|
448
|
-
: {}),
|
|
449
|
-
messages
|
|
450
|
-
});
|
|
451
|
-
};
|
|
452
|
-
|
|
453
|
-
let extracted = 0;
|
|
454
|
-
let failed = 0;
|
|
455
|
-
let aborted = false;
|
|
456
|
-
const extractedIds = [];
|
|
457
|
-
for (const m of candidates) {
|
|
458
|
-
if (signal?.aborted) {
|
|
459
|
-
aborted = true;
|
|
460
|
-
break;
|
|
461
|
-
}
|
|
462
|
-
const now = new Date().toISOString();
|
|
463
|
-
try {
|
|
464
|
-
// Stamp a pending marker BEFORE the LLM call so a crash mid-extract (the
|
|
465
|
-
// stamp and the entity writes are not one transaction) cannot re-queue
|
|
466
|
-
// this memory while we are already working it. Cleared on outcome.
|
|
467
|
-
service.setMemoryMetadata?.(m.id, { pending_extracted_at: now });
|
|
468
|
-
} catch (error) {
|
|
469
|
-
logger?.warn?.(`dsh-mneme sleep: failed to stamp pending_extracted_at for ${m.id}: ${String(error)}`);
|
|
470
|
-
failed++;
|
|
471
|
-
continue;
|
|
472
|
-
}
|
|
473
|
-
try {
|
|
474
|
-
const result = await extractEntities(m, { store: service, config, callLLM, logger });
|
|
475
|
-
if (result?.ok) {
|
|
476
|
-
extracted++;
|
|
477
|
-
extractedIds.push(m.id);
|
|
478
|
-
// Stamp done regardless of whether the LLM found entities — an empty
|
|
479
|
-
// extract is still a definitive answer for this memory.
|
|
480
|
-
try {
|
|
481
|
-
service.setMemoryMetadata?.(m.id, { entity_extracted_at: now, pending_extracted_at: null });
|
|
482
|
-
} catch (error) {
|
|
483
|
-
logger?.warn?.(`dsh-mneme sleep: failed to stamp entity_extracted_at for ${m.id}: ${String(error)}`);
|
|
484
|
-
}
|
|
485
|
-
} else {
|
|
486
|
-
failed++;
|
|
487
|
-
logger?.warn?.(`dsh-mneme sleep: entity extraction failed for ${m.id}: ${result?.error ?? "unknown"}`);
|
|
488
|
-
try {
|
|
489
|
-
service.setMemoryMetadata?.(m.id, { pending_extracted_at: null });
|
|
490
|
-
} catch (error) {
|
|
491
|
-
logger?.warn?.(`dsh-mneme sleep: failed to clear pending_extracted_at for ${m.id}: ${String(error)}`);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
} catch (error) {
|
|
495
|
-
failed++;
|
|
496
|
-
logger?.warn?.(`dsh-mneme sleep: entity extraction threw for ${m.id}: ${String(error)}`);
|
|
497
|
-
try {
|
|
498
|
-
service.setMemoryMetadata?.(m.id, { pending_extracted_at: null });
|
|
499
|
-
} catch (err) {
|
|
500
|
-
logger?.warn?.(`dsh-mneme sleep: failed to clear pending_extracted_at for ${m.id}: ${String(err)}`);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
return {
|
|
505
|
-
// Any success counts as ok (failures are surfaced via detail.failed + warn,
|
|
506
|
-
// matching phaseConflicts); all-failed reports failed so deriveStatus can
|
|
507
|
-
// reflect a broken route without aborting the other phases.
|
|
508
|
-
status: aborted ? "aborted" : extracted > 0 ? "ok" : failed > 0 ? "failed" : "noop",
|
|
509
|
-
scanned: candidates.length,
|
|
510
|
-
extracted,
|
|
511
|
-
failed,
|
|
512
|
-
aborted,
|
|
513
|
-
extractedIds
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
|
-
|
|
517
380
|
// ---------------------------------------------------------------- run
|
|
518
381
|
|
|
519
382
|
function deriveStatus(phases) {
|
|
@@ -547,9 +410,6 @@ export async function runSleep(ctx, service, config, logger, semantic = null, si
|
|
|
547
410
|
await attempt("conflicts", () => phaseConflicts(ctx, service, config, logger, runId, semantic, signal));
|
|
548
411
|
await attempt("demotion", () => phaseDemotion(service, config, logger, runId, signal));
|
|
549
412
|
await attempt("patterns", () => phasePatterns(ctx, service, config, logger, runId, signal));
|
|
550
|
-
// entity-extraction runs before relation completion so freshly minted
|
|
551
|
-
// entities get their orphan relations completed in the same cycle.
|
|
552
|
-
await attempt("entity-extraction", () => phaseEntityExtraction(ctx, service, config, logger, runId, signal));
|
|
553
413
|
await attempt("relations", () => phaseRelations(service, config, logger, runId, signal));
|
|
554
414
|
|
|
555
415
|
const status = deriveStatus(phases);
|