@modusensus/dsh-mneme 0.6.7 → 0.6.8
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/LICENSE +21 -21
- package/README.md +463 -463
- package/cordis.patch.yml +15 -15
- package/lib/api.js +783 -783
- package/lib/client.js +1757 -1757
- package/lib/commands.js +64 -64
- package/lib/config.js +288 -288
- package/lib/dream/clustering.js +118 -118
- package/lib/dream/decisions.js +439 -439
- package/lib/dream/sleep.js +561 -554
- package/lib/dream/tag-extractor.js +156 -156
- package/lib/dream.js +935 -929
- package/lib/embedding.js +154 -154
- package/lib/entities/extractor.js +242 -242
- package/lib/hot-memory.js +53 -53
- package/lib/index.js +361 -361
- package/lib/inject.js +208 -208
- package/lib/local-embedder.js +282 -282
- package/lib/mirror.js +170 -170
- package/lib/parser/tag.js +59 -59
- package/lib/parser/wiki-link.js +38 -38
- package/lib/quality-filter.js +123 -123
- package/lib/reranker.js +218 -218
- package/lib/search/adaptive.js +22 -22
- package/lib/search/bm25.js +96 -96
- package/lib/search/tag-boost.js +61 -61
- package/lib/service.js +1726 -1726
- package/lib/settings.js +172 -172
- package/lib/store.js +2238 -2238
- package/lib/summarize.js +236 -236
- package/lib/tools.js +290 -290
- package/lib/vector-index.js +116 -116
- package/package.json +80 -80
- package/scripts/benchmark-embed.js +201 -201
- package/scripts/benchmark-recall.js +133 -133
- package/scripts/benchmark-rerank.js +166 -166
- package/scripts/e2e-dsh.js +218 -218
- package/scripts/stress-dsh.js +255 -255
- package/scripts/sync-lib.js +52 -52
- package/src/api.js +783 -783
- package/src/commands.js +64 -64
- package/src/config.js +288 -288
- package/src/dream/clustering.js +118 -118
- package/src/dream/decisions.js +439 -439
- package/src/dream/sleep.js +561 -554
- package/src/dream/tag-extractor.js +156 -156
- package/src/dream.js +935 -929
- package/src/embedding.js +154 -154
- package/src/entities/extractor.js +242 -242
- package/src/hot-memory.js +53 -53
- package/src/index.js +361 -361
- package/src/inject.js +208 -208
- package/src/local-embedder.js +282 -282
- package/src/mirror.js +170 -170
- package/src/parser/tag.js +59 -59
- package/src/parser/wiki-link.js +38 -38
- package/src/quality-filter.js +123 -123
- package/src/reranker.js +218 -218
- package/src/search/adaptive.js +22 -22
- package/src/search/bm25.js +96 -96
- package/src/search/tag-boost.js +61 -61
- package/src/service.js +1726 -1726
- package/src/settings.js +172 -172
- package/src/store.js +2238 -2238
- package/src/summarize.js +236 -236
- package/src/tools.js +290 -290
- package/src/vector-index.js +116 -116
- package/test/api.test.js +594 -594
- package/test/audit.test.js +448 -448
- package/test/benchmark.test.js +35 -35
- package/test/boundary-v0625.test.js +82 -82
- package/test/client.test.js +368 -368
- package/test/clustering.test.js +100 -100
- package/test/commands.test.js +69 -69
- package/test/config.test.js +50 -50
- package/test/conflict-freeze.test.js +290 -290
- package/test/directory.test.js +134 -134
- package/test/dream.test.js +903 -901
- package/test/entities.test.js +522 -522
- package/test/epistemic.test.js +298 -298
- package/test/fnew-0112.test.js +311 -311
- package/test/fnew-03.test.js +422 -422
- package/test/graph-api.test.js +175 -175
- package/test/helpers/dream-mock.js +82 -82
- package/test/hot-memory.test.js +174 -174
- package/test/inject.test.js +103 -103
- package/test/llm-audit.test.js +279 -279
- package/test/local-embedder.test.js +227 -227
- package/test/mirror-dirty.test.js +424 -424
- package/test/mirror-edit-digest.test.js +187 -187
- package/test/mirror-generation.test.js +499 -499
- package/test/mirror.test.js +249 -249
- package/test/normalize-decisions.test.js +120 -120
- package/test/peer-blockers.test.js +190 -190
- package/test/policy-epoch.test.js +259 -259
- package/test/provenance.test.js +103 -103
- package/test/quality-filter.test.js +118 -118
- package/test/reasoning-effort.test.js +199 -199
- package/test/recall-evals.test.js +235 -235
- package/test/recall-layer.test.js +315 -315
- package/test/receipt-chain.test.js +451 -451
- package/test/reflection.test.js +226 -226
- package/test/reranker.test.js +240 -240
- package/test/search-fusion.test.js +90 -90
- package/test/semantic.test.js +124 -124
- package/test/service-search.test.js +199 -199
- package/test/service.test.js +435 -435
- package/test/settings.test.js +118 -118
- package/test/sleep.test.js +365 -365
- package/test/store.test.js +436 -436
- package/test/stress.test.js +209 -209
- package/test/summarize.test.js +191 -191
- package/test/tag-boost.test.js +125 -125
- package/test/tag.test.js +312 -312
- package/test/tools.test.js +285 -285
- package/test/vector-index.test.js +221 -221
- package/test/wiki-link.test.js +332 -332
package/lib/dream/decisions.js
CHANGED
|
@@ -1,439 +1,439 @@
|
|
|
1
|
-
const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update", "create"]);
|
|
2
|
-
|
|
3
|
-
// Epistemic trust (v0.4.5): when config.trustEpistemicWeighting is on, merge
|
|
4
|
-
// keepSource and conflict winners prefer the higher-trust memory. Higher value
|
|
5
|
-
// = preferred. observation (measured) > inferred (derived) > subjective (guess).
|
|
6
|
-
const EPISTEMIC_PRIORITY = { observation: 3, inferred: 2, subjective: 1 };
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Validate a dream decision list against a snapshot of eligible memories.
|
|
10
|
-
* @param decisions - LLM-produced decision list.
|
|
11
|
-
* @param snapshot - Map<id, memory> of eligible (non-archived, non-summary) entries.
|
|
12
|
-
* @returns {{ok: boolean, errors: string[]}}
|
|
13
|
-
*/
|
|
14
|
-
export function validateDecisions(decisions, snapshot, options = {}) {
|
|
15
|
-
const errors = [];
|
|
16
|
-
const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
|
|
17
|
-
const minAgeHours = options.minAgeHours ?? 24;
|
|
18
|
-
if (!Array.isArray(decisions) || decisions.length === 0) {
|
|
19
|
-
return { ok: false, errors: ["decision list must be a non-empty array"] };
|
|
20
|
-
}
|
|
21
|
-
const claimed = new Set();
|
|
22
|
-
for (const [index, d] of decisions.entries()) {
|
|
23
|
-
const at = `decision[${index}]`;
|
|
24
|
-
if (!d || typeof d !== "object" || !ACTIONS.has(d.action)) {
|
|
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;
|
|
33
|
-
}
|
|
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;
|
|
41
|
-
}
|
|
42
|
-
if (typeof d.content !== "string" || !d.content.trim()) {
|
|
43
|
-
errors.push(`${at}: create needs non-empty content`);
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
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`);
|
|
51
|
-
}
|
|
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
|
-
}
|
|
86
|
-
}
|
|
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
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
// Cap update churn: too many edits in one cycle signals a runaway model
|
|
118
|
-
const updateCount = decisions.filter((d) => d.action === "update").length;
|
|
119
|
-
if (updateCount > maxUpdatePerRun) {
|
|
120
|
-
errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
|
|
121
|
-
}
|
|
122
|
-
// Cap pattern minting per run (sleepMaxPatternPerRun passes through here).
|
|
123
|
-
const createCount = decisions.filter((d) => d.action === "create").length;
|
|
124
|
-
const maxCreatePerRun = options.maxCreatePerRun ?? 5;
|
|
125
|
-
if (createCount > maxCreatePerRun) {
|
|
126
|
-
errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
|
|
127
|
-
}
|
|
128
|
-
// v0.4.4: 隐式 keep。默认(dreamImplicitKeep !== false)下,未 claim 的
|
|
129
|
-
// snapshot 记忆自动补 {action:"keep"},而不是整体拒绝——大记忆量下 LLM 漏报
|
|
130
|
-
// 一两条就全拒(636 记忆 → 677 errors)会白白浪费整轮 run。设 false 则保留
|
|
131
|
-
// 旧的严格"全量覆盖"校验。补齐的 keep 直接 append 到 decisions,调用方
|
|
132
|
-
// (runDream/applyDecisions/audit)复用同一数组即可覆盖全部 snapshot 记忆。
|
|
133
|
-
//
|
|
134
|
-
// v0.4.4 fix(残缺输出防洗白):先收集所有非覆盖类 errors,有错直接 ok:false
|
|
135
|
-
// 且绝不 push 任何补齐 keep——残缺决策必须被真实拒绝,不能被隐式 keep 洗白成
|
|
136
|
-
// ok 后再 apply。只有无错时才检查显式覆盖率:LLM 输出被截断只 claim 少量
|
|
137
|
-
// snapshot(claimed.size / snapshot.size < dreamMinExplicitCoverage)时整单拒绝,
|
|
138
|
-
// 而不是用 keep 把绝大部分 snapshot 全部"通过"。
|
|
139
|
-
if (errors.length > 0) {
|
|
140
|
-
return { ok: false, errors };
|
|
141
|
-
}
|
|
142
|
-
const minCoverage = options.dreamMinExplicitCoverage ?? 0.5;
|
|
143
|
-
if (options.dreamImplicitKeep !== false) {
|
|
144
|
-
const coverage = snapshot.size > 0 ? claimed.size / snapshot.size : 1;
|
|
145
|
-
if (coverage < minCoverage) {
|
|
146
|
-
errors.push(`explicit decision coverage ${Math.round(coverage * 100)}% < minimum ${Math.round(minCoverage * 100)}%`);
|
|
147
|
-
return { ok: false, errors };
|
|
148
|
-
}
|
|
149
|
-
for (const id of snapshot.keys()) {
|
|
150
|
-
if (!claimed.has(id)) decisions.push({ action: "keep", ids: [id] });
|
|
151
|
-
}
|
|
152
|
-
} else {
|
|
153
|
-
for (const id of snapshot.keys()) {
|
|
154
|
-
if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
|
|
155
|
-
}
|
|
156
|
-
if (errors.length > 0) return { ok: false, errors };
|
|
157
|
-
}
|
|
158
|
-
return { ok: true, errors };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** Marker thrown when a decision target changed since the run snapshot. */
|
|
162
|
-
export class CasConflictError extends Error {
|
|
163
|
-
constructor(action, ids, reason) {
|
|
164
|
-
super(`cas conflict: ${action} targets changed since snapshot (${reason})`);
|
|
165
|
-
this.name = "CasConflictError";
|
|
166
|
-
this.action = action;
|
|
167
|
-
this.ids = ids;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function decisionIds(d) {
|
|
172
|
-
return d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* CAS guard (item ①): every target memory must still match what the run
|
|
177
|
-
* snapshot captured — otherwise the decision was computed against stale state
|
|
178
|
-
* and applying it would overwrite a concurrent edit. Snapshotless replays skip
|
|
179
|
-
* the guard entirely (per-action idempotency checks handle those). Throws
|
|
180
|
-
* CasConflictError on the first mismatch; the caller's transaction rolls back.
|
|
181
|
-
*/
|
|
182
|
-
function casGuard(service, snapshot, ids) {
|
|
183
|
-
if (!snapshot) return;
|
|
184
|
-
for (const id of ids) {
|
|
185
|
-
const expect = snapshot.get(id);
|
|
186
|
-
if (!expect) continue; // not in snapshot: validated elsewhere, skip guard
|
|
187
|
-
const current = service.getById(id);
|
|
188
|
-
if (!current) {
|
|
189
|
-
throw new CasConflictError("deleted", [id], `memory ${id} was removed`);
|
|
190
|
-
}
|
|
191
|
-
const changed = expect.updated_at !== undefined
|
|
192
|
-
? current.updated_at !== expect.updated_at
|
|
193
|
-
: current.content !== expect.content || current.title !== expect.title;
|
|
194
|
-
if (changed) {
|
|
195
|
-
throw new CasConflictError(
|
|
196
|
-
"changed",
|
|
197
|
-
[id],
|
|
198
|
-
`memory ${id} was concurrently modified (expected updated_at=${expect.updated_at}, got ${current.updated_at})`
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Apply a validated decision list to the service. Caller must validate first.
|
|
206
|
-
* Each decision runs inside its own SQLite transaction (item ②): the multi-step
|
|
207
|
-
* mutation of a decision is atomic, so a merge can never leave "keeper updated
|
|
208
|
-
* but source not archived" or vice versa — a throwing sub-step rolls the whole
|
|
209
|
-
* decision back.
|
|
210
|
-
*
|
|
211
|
-
* @param decisions - validated decision list.
|
|
212
|
-
* @param service - memory service (saveWithDedupe/getById/update/setArchived/transaction).
|
|
213
|
-
* @param logger - optional logger ({ warn }); per-decision failures are logged.
|
|
214
|
-
* @param snapshot - optional Map<id, memory> captured before the LLM call; when
|
|
215
|
-
* provided, every decision target is CAS-checked against it and a decision
|
|
216
|
-
* computed from stale state is skipped and reported as a conflict instead of
|
|
217
|
-
* overwriting concurrent writes (item ①).
|
|
218
|
-
* @returns {{ applied: number, conflicts: Array, failures: Array, committed: Array }}
|
|
219
|
-
* applied - number of decisions/memories actually committed (archive counts
|
|
220
|
-
* each archived memory as one, merge/conflict/update count one).
|
|
221
|
-
* conflicts - decisions skipped because a target changed since the snapshot.
|
|
222
|
-
* failures - decisions that threw mid-transaction (fully rolled back).
|
|
223
|
-
* committed - the decisions that actually landed, for outcome/receipt based
|
|
224
|
-
* on real committed sub-steps rather than the raw LLM list.
|
|
225
|
-
*/
|
|
226
|
-
export function applyDecisions(decisions, service, logger = null, snapshot = null, config = {}) {
|
|
227
|
-
let applied = 0;
|
|
228
|
-
const conflicts = [];
|
|
229
|
-
const failures = [];
|
|
230
|
-
const committed = [];
|
|
231
|
-
for (const [i, d] of decisions.entries()) {
|
|
232
|
-
try {
|
|
233
|
-
// keep is a confirmed no-op: it commits nothing but still records the
|
|
234
|
-
// per-id disposition so the outcome covers every snapshot memory.
|
|
235
|
-
if (d.action === "keep") {
|
|
236
|
-
committed.push({ action: "keep", ids: d.ids });
|
|
237
|
-
continue;
|
|
238
|
-
}
|
|
239
|
-
const outcome = applyOne(d, service, snapshot, config);
|
|
240
|
-
if (outcome === "skipped") continue;
|
|
241
|
-
applied += outcome.applied;
|
|
242
|
-
committed.push(outcome.committed);
|
|
243
|
-
} catch (error) {
|
|
244
|
-
if (error instanceof CasConflictError) {
|
|
245
|
-
conflicts.push({ index: i, action: d.action, ids: error.ids, reason: error.message });
|
|
246
|
-
logger?.warn?.(`dsh-mneme dream: ${error.message}`);
|
|
247
|
-
} else {
|
|
248
|
-
failures.push({ index: i, action: d.action, ids: decisionIds(d), reason: error.message });
|
|
249
|
-
logger?.warn?.(`dsh-mneme dream: failed to apply ${d.action} at index ${i}: ${error.message}`);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
return { applied, conflicts, failures, committed };
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function applyOne(d, service, snapshot, config = {}) {
|
|
257
|
-
switch (d.action) {
|
|
258
|
-
case "archive": return applyArchive(d, service, snapshot);
|
|
259
|
-
case "merge": return applyMerge(d, service, snapshot, config);
|
|
260
|
-
case "conflict": return applyConflict(d, service, snapshot, config);
|
|
261
|
-
case "create": return applyCreate(d, service, config);
|
|
262
|
-
default: return applyUpdate(d, service, snapshot, config);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Highest-epistemic-priority UNARCHIVED id among `ids` (ties break toward
|
|
268
|
-
* `preferred`). Archived memories are never eligible keepers — promoting one
|
|
269
|
-
* would demote the real keepSource to a source and then hit the archived-keeper
|
|
270
|
-
* guard in applyMerge, silently skipping the whole merge. When `preferred`
|
|
271
|
-
* itself is archived (or missing), fall back to any unarchived candidate.
|
|
272
|
-
*/
|
|
273
|
-
function pickBestKeeper(ids, preferred, service) {
|
|
274
|
-
let best = null;
|
|
275
|
-
let bestP = -1;
|
|
276
|
-
for (const id of ids) {
|
|
277
|
-
const mem = service.getById(id);
|
|
278
|
-
if (!mem || mem.archived) continue; // archived/missing: ineligible keeper
|
|
279
|
-
const p = EPISTEMIC_PRIORITY[mem.epistemic_status] ?? 0;
|
|
280
|
-
if (p > bestP || (p === bestP && id === preferred)) {
|
|
281
|
-
bestP = p;
|
|
282
|
-
best = id;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
return best ?? preferred;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Mint a fresh memory (pattern discovery). No existing target, so no CAS guard.
|
|
290
|
-
* Evidence ids ride in the content so a pattern stays traceable to its source
|
|
291
|
-
* memories. saveWithDedupe dedupes identical mints (idempotent replay-safe).
|
|
292
|
-
*/
|
|
293
|
-
function applyCreate(d, service, config = {}) {
|
|
294
|
-
const title = String(d.title ?? "").trim();
|
|
295
|
-
const content = String(d.content ?? "").trim();
|
|
296
|
-
const importance = Number.isInteger(d.importance) ? d.importance : 3;
|
|
297
|
-
const type = typeof d.type === "string" ? d.type : "pattern";
|
|
298
|
-
const evidence = Array.isArray(d.evidence)
|
|
299
|
-
? d.evidence.filter((id) => typeof id === "string")
|
|
300
|
-
: [];
|
|
301
|
-
const body = evidence.length > 0
|
|
302
|
-
? `${content}\n\n[证据: ${evidence.join(", ")}]`
|
|
303
|
-
: content;
|
|
304
|
-
const created = service.saveWithDedupe({ type, title, content: body, importance });
|
|
305
|
-
const memory = created?.memory;
|
|
306
|
-
if (!memory) return "skipped"; // deduped/subsumed: nothing minted, clean no-op
|
|
307
|
-
return { applied: 1, committed: { action: "create", id: memory.id, type } };
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function applyArchive(d, service, snapshot) {
|
|
311
|
-
const targets = d.ids.filter((id) => {
|
|
312
|
-
const mem = service.getById(id);
|
|
313
|
-
return mem && !mem.archived; // existing, not-yet-archived rows only
|
|
314
|
-
});
|
|
315
|
-
if (targets.length === 0) return "skipped"; // all already archived: idempotent replay
|
|
316
|
-
service.transaction(() => {
|
|
317
|
-
casGuard(service, snapshot, d.ids);
|
|
318
|
-
for (const id of d.ids) {
|
|
319
|
-
const mem = service.getById(id);
|
|
320
|
-
if (mem && !mem.archived) service.setArchived(id, true);
|
|
321
|
-
}
|
|
322
|
-
});
|
|
323
|
-
return { applied: targets.length, committed: { action: "archive", ids: targets } };
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function applyMerge(d, service, snapshot, config = {}) {
|
|
327
|
-
// Epistemic trust (v0.4.5): when enabled, prefer an observation keeper over a
|
|
328
|
-
// subjective/inferred one. Mutating the decision keeps the receipt + committed
|
|
329
|
-
// record aligned with the actual keeper.
|
|
330
|
-
if (config.trustEpistemicWeighting === true) {
|
|
331
|
-
const best = pickBestKeeper(d.ids, d.keepSource, service);
|
|
332
|
-
if (best && best !== d.keepSource) d.keepSource = best;
|
|
333
|
-
}
|
|
334
|
-
const sources = d.ids.filter((id) => id !== d.keepSource);
|
|
335
|
-
// Idempotent replay: if every other source is already archived, this merge
|
|
336
|
-
// already landed — skip so a replayed/concurrent decision never double-counts
|
|
337
|
-
// or re-applies (guard against duplicate merges).
|
|
338
|
-
if (sources.every((id) => service.getById(id)?.archived)) return "skipped";
|
|
339
|
-
service.transaction(() => {
|
|
340
|
-
casGuard(service, snapshot, d.ids);
|
|
341
|
-
const keeper = service.getById(d.keepSource);
|
|
342
|
-
if (!keeper || keeper.archived) return; // missing keeper: no write, still a clean commit
|
|
343
|
-
service.update(d.keepSource, {
|
|
344
|
-
title: d.title,
|
|
345
|
-
content: d.content,
|
|
346
|
-
importance: d.importance ?? Math.max(keeper.importance, ...d.ids.map((id) => service.getById(id)?.importance ?? 1))
|
|
347
|
-
});
|
|
348
|
-
for (const id of sources) {
|
|
349
|
-
const mem = service.getById(id);
|
|
350
|
-
if (mem && !mem.archived) service.setArchived(id, true);
|
|
351
|
-
}
|
|
352
|
-
// 4.3.2 迁移实体关联(opt-in):将 loser(source)记忆关联的 entity_attrs 的
|
|
353
|
-
// memory_id 迁移到 keeper;keeper 已有同 entity+key 的当前属性时 loser 行被
|
|
354
|
-
// 失效。单个 source 迁移失败只告警,绝不能导致整个 merge 事务回滚(fail-safe)。
|
|
355
|
-
if (config.entityExtractionEnabled && typeof service.migrateAttrsToMemory === "function") {
|
|
356
|
-
for (const id of sources) {
|
|
357
|
-
try {
|
|
358
|
-
service.migrateAttrsToMemory(id, d.keepSource, new Date().toISOString());
|
|
359
|
-
} catch (error) {
|
|
360
|
-
logger?.warn?.(`dsh-mneme dream: failed to migrate attrs from ${id} to ${d.keepSource}: ${error.message}`);
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
});
|
|
365
|
-
return {
|
|
366
|
-
applied: 1,
|
|
367
|
-
committed: { action: "merge", ids: d.ids, keepSource: d.keepSource, title: d.title, content: d.content, importance: d.importance, count_before: d.ids.length, count_after: 1 }
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function applyConflict(d, service, snapshot, config = {}) {
|
|
372
|
-
// Epistemic trust (v0.4.5): when enabled, the observation side of a conflict
|
|
373
|
-
// is preferred as winner over a subjective/inferred one.
|
|
374
|
-
if (config.trustEpistemicWeighting === true) {
|
|
375
|
-
const pw = EPISTEMIC_PRIORITY[service.getById(d.winner)?.epistemic_status] ?? 0;
|
|
376
|
-
const pl = EPISTEMIC_PRIORITY[service.getById(d.loser)?.epistemic_status] ?? 0;
|
|
377
|
-
if (pl > pw) [d.winner, d.loser] = [d.loser, d.winner];
|
|
378
|
-
}
|
|
379
|
-
const winner = service.getById(d.winner);
|
|
380
|
-
const loser = service.getById(d.loser);
|
|
381
|
-
if (!winner || !loser) return "skipped";
|
|
382
|
-
// Idempotent replay: an already-archived loser means the conflict was already
|
|
383
|
-
// adjudicated — skip so the provenance note is never re-appended and the loser
|
|
384
|
-
// is not re-archived.
|
|
385
|
-
if (loser.archived) return "skipped";
|
|
386
|
-
service.transaction(() => {
|
|
387
|
-
casGuard(service, snapshot, [d.winner, d.loser]);
|
|
388
|
-
const winnerNow = service.getById(d.winner);
|
|
389
|
-
const loserNow = service.getById(d.loser);
|
|
390
|
-
if (!winnerNow || !loserNow || loserNow.archived) return;
|
|
391
|
-
service.update(d.winner, {
|
|
392
|
-
content: `${winnerNow.content}\n\n(已否决旧信息:${[...loserNow.content].slice(0, 100).join("")})`
|
|
393
|
-
});
|
|
394
|
-
service.setArchived(d.loser, true);
|
|
395
|
-
});
|
|
396
|
-
return { applied: 1, committed: { action: "conflict", winner: d.winner, loser: d.loser, count_before: 2, count_after: 1 } };
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
function applyUpdate(d, service, snapshot, config = {}) {
|
|
400
|
-
const id = d.ids[0];
|
|
401
|
-
const mem = service.getById(id);
|
|
402
|
-
if (!mem || mem.archived) return "skipped";
|
|
403
|
-
// 幂等检查:如果字段已与目标一致则跳过
|
|
404
|
-
const same = (d.title === undefined || d.title === mem.title)
|
|
405
|
-
&& (d.content === undefined || d.content === mem.content)
|
|
406
|
-
&& (d.importance === undefined || d.importance === mem.importance);
|
|
407
|
-
if (same) return "skipped";
|
|
408
|
-
service.transaction(() => {
|
|
409
|
-
casGuard(service, snapshot, [id]);
|
|
410
|
-
const cur = service.getById(id);
|
|
411
|
-
if (!cur || cur.archived) return;
|
|
412
|
-
service.update(id, {
|
|
413
|
-
title: d.title ?? cur.title,
|
|
414
|
-
content: d.content ?? cur.content,
|
|
415
|
-
importance: d.importance ?? cur.importance
|
|
416
|
-
});
|
|
417
|
-
});
|
|
418
|
-
// 4.3.1 supersedes 关系(opt-in):事务提交成功后,为该记忆关联的每条实体属性
|
|
419
|
-
// 建立自引用 supersedes 关系,表示"此属性版本已被替代"。仅记录、绝不阻断主流程
|
|
420
|
-
// (fail-safe):记录失败只告警,update 本身照常生效。
|
|
421
|
-
if (config.entityExtractionEnabled && typeof service.saveRelation === "function" && typeof service.getAttrsByMemory === "function") {
|
|
422
|
-
try {
|
|
423
|
-
const oldAttrs = service.getAttrsByMemory(id);
|
|
424
|
-
for (const attr of oldAttrs) {
|
|
425
|
-
if (!attr.entity_id) continue;
|
|
426
|
-
service.saveRelation({
|
|
427
|
-
from_entity: attr.entity_id,
|
|
428
|
-
to_entity: attr.entity_id,
|
|
429
|
-
relation_type: "supersedes",
|
|
430
|
-
memory_id: id,
|
|
431
|
-
metadata: JSON.stringify({ attr_key: attr.attr_key, old_value: attr.attr_value })
|
|
432
|
-
});
|
|
433
|
-
}
|
|
434
|
-
} catch (error) {
|
|
435
|
-
logger?.warn?.(`dsh-mneme dream: failed to record supersedes relations for ${id}: ${error.message}`);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
return { applied: 1, committed: { action: "update", ids: [id], title: d.title, content: d.content, importance: d.importance, count_before: 1, count_after: 1 } };
|
|
439
|
-
}
|
|
1
|
+
const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update", "create"]);
|
|
2
|
+
|
|
3
|
+
// Epistemic trust (v0.4.5): when config.trustEpistemicWeighting is on, merge
|
|
4
|
+
// keepSource and conflict winners prefer the higher-trust memory. Higher value
|
|
5
|
+
// = preferred. observation (measured) > inferred (derived) > subjective (guess).
|
|
6
|
+
const EPISTEMIC_PRIORITY = { observation: 3, inferred: 2, subjective: 1 };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validate a dream decision list against a snapshot of eligible memories.
|
|
10
|
+
* @param decisions - LLM-produced decision list.
|
|
11
|
+
* @param snapshot - Map<id, memory> of eligible (non-archived, non-summary) entries.
|
|
12
|
+
* @returns {{ok: boolean, errors: string[]}}
|
|
13
|
+
*/
|
|
14
|
+
export function validateDecisions(decisions, snapshot, options = {}) {
|
|
15
|
+
const errors = [];
|
|
16
|
+
const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
|
|
17
|
+
const minAgeHours = options.minAgeHours ?? 24;
|
|
18
|
+
if (!Array.isArray(decisions) || decisions.length === 0) {
|
|
19
|
+
return { ok: false, errors: ["decision list must be a non-empty array"] };
|
|
20
|
+
}
|
|
21
|
+
const claimed = new Set();
|
|
22
|
+
for (const [index, d] of decisions.entries()) {
|
|
23
|
+
const at = `decision[${index}]`;
|
|
24
|
+
if (!d || typeof d !== "object" || !ACTIONS.has(d.action)) {
|
|
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;
|
|
33
|
+
}
|
|
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;
|
|
41
|
+
}
|
|
42
|
+
if (typeof d.content !== "string" || !d.content.trim()) {
|
|
43
|
+
errors.push(`${at}: create needs non-empty content`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
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`);
|
|
51
|
+
}
|
|
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
|
+
}
|
|
86
|
+
}
|
|
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
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Cap update churn: too many edits in one cycle signals a runaway model
|
|
118
|
+
const updateCount = decisions.filter((d) => d.action === "update").length;
|
|
119
|
+
if (updateCount > maxUpdatePerRun) {
|
|
120
|
+
errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
|
|
121
|
+
}
|
|
122
|
+
// Cap pattern minting per run (sleepMaxPatternPerRun passes through here).
|
|
123
|
+
const createCount = decisions.filter((d) => d.action === "create").length;
|
|
124
|
+
const maxCreatePerRun = options.maxCreatePerRun ?? 5;
|
|
125
|
+
if (createCount > maxCreatePerRun) {
|
|
126
|
+
errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
|
|
127
|
+
}
|
|
128
|
+
// v0.4.4: 隐式 keep。默认(dreamImplicitKeep !== false)下,未 claim 的
|
|
129
|
+
// snapshot 记忆自动补 {action:"keep"},而不是整体拒绝——大记忆量下 LLM 漏报
|
|
130
|
+
// 一两条就全拒(636 记忆 → 677 errors)会白白浪费整轮 run。设 false 则保留
|
|
131
|
+
// 旧的严格"全量覆盖"校验。补齐的 keep 直接 append 到 decisions,调用方
|
|
132
|
+
// (runDream/applyDecisions/audit)复用同一数组即可覆盖全部 snapshot 记忆。
|
|
133
|
+
//
|
|
134
|
+
// v0.4.4 fix(残缺输出防洗白):先收集所有非覆盖类 errors,有错直接 ok:false
|
|
135
|
+
// 且绝不 push 任何补齐 keep——残缺决策必须被真实拒绝,不能被隐式 keep 洗白成
|
|
136
|
+
// ok 后再 apply。只有无错时才检查显式覆盖率:LLM 输出被截断只 claim 少量
|
|
137
|
+
// snapshot(claimed.size / snapshot.size < dreamMinExplicitCoverage)时整单拒绝,
|
|
138
|
+
// 而不是用 keep 把绝大部分 snapshot 全部"通过"。
|
|
139
|
+
if (errors.length > 0) {
|
|
140
|
+
return { ok: false, errors };
|
|
141
|
+
}
|
|
142
|
+
const minCoverage = options.dreamMinExplicitCoverage ?? 0.5;
|
|
143
|
+
if (options.dreamImplicitKeep !== false) {
|
|
144
|
+
const coverage = snapshot.size > 0 ? claimed.size / snapshot.size : 1;
|
|
145
|
+
if (coverage < minCoverage) {
|
|
146
|
+
errors.push(`explicit decision coverage ${Math.round(coverage * 100)}% < minimum ${Math.round(minCoverage * 100)}%`);
|
|
147
|
+
return { ok: false, errors };
|
|
148
|
+
}
|
|
149
|
+
for (const id of snapshot.keys()) {
|
|
150
|
+
if (!claimed.has(id)) decisions.push({ action: "keep", ids: [id] });
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
for (const id of snapshot.keys()) {
|
|
154
|
+
if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
|
|
155
|
+
}
|
|
156
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
157
|
+
}
|
|
158
|
+
return { ok: true, errors };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Marker thrown when a decision target changed since the run snapshot. */
|
|
162
|
+
export class CasConflictError extends Error {
|
|
163
|
+
constructor(action, ids, reason) {
|
|
164
|
+
super(`cas conflict: ${action} targets changed since snapshot (${reason})`);
|
|
165
|
+
this.name = "CasConflictError";
|
|
166
|
+
this.action = action;
|
|
167
|
+
this.ids = ids;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function decisionIds(d) {
|
|
172
|
+
return d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* CAS guard (item ①): every target memory must still match what the run
|
|
177
|
+
* snapshot captured — otherwise the decision was computed against stale state
|
|
178
|
+
* and applying it would overwrite a concurrent edit. Snapshotless replays skip
|
|
179
|
+
* the guard entirely (per-action idempotency checks handle those). Throws
|
|
180
|
+
* CasConflictError on the first mismatch; the caller's transaction rolls back.
|
|
181
|
+
*/
|
|
182
|
+
function casGuard(service, snapshot, ids) {
|
|
183
|
+
if (!snapshot) return;
|
|
184
|
+
for (const id of ids) {
|
|
185
|
+
const expect = snapshot.get(id);
|
|
186
|
+
if (!expect) continue; // not in snapshot: validated elsewhere, skip guard
|
|
187
|
+
const current = service.getById(id);
|
|
188
|
+
if (!current) {
|
|
189
|
+
throw new CasConflictError("deleted", [id], `memory ${id} was removed`);
|
|
190
|
+
}
|
|
191
|
+
const changed = expect.updated_at !== undefined
|
|
192
|
+
? current.updated_at !== expect.updated_at
|
|
193
|
+
: current.content !== expect.content || current.title !== expect.title;
|
|
194
|
+
if (changed) {
|
|
195
|
+
throw new CasConflictError(
|
|
196
|
+
"changed",
|
|
197
|
+
[id],
|
|
198
|
+
`memory ${id} was concurrently modified (expected updated_at=${expect.updated_at}, got ${current.updated_at})`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Apply a validated decision list to the service. Caller must validate first.
|
|
206
|
+
* Each decision runs inside its own SQLite transaction (item ②): the multi-step
|
|
207
|
+
* mutation of a decision is atomic, so a merge can never leave "keeper updated
|
|
208
|
+
* but source not archived" or vice versa — a throwing sub-step rolls the whole
|
|
209
|
+
* decision back.
|
|
210
|
+
*
|
|
211
|
+
* @param decisions - validated decision list.
|
|
212
|
+
* @param service - memory service (saveWithDedupe/getById/update/setArchived/transaction).
|
|
213
|
+
* @param logger - optional logger ({ warn }); per-decision failures are logged.
|
|
214
|
+
* @param snapshot - optional Map<id, memory> captured before the LLM call; when
|
|
215
|
+
* provided, every decision target is CAS-checked against it and a decision
|
|
216
|
+
* computed from stale state is skipped and reported as a conflict instead of
|
|
217
|
+
* overwriting concurrent writes (item ①).
|
|
218
|
+
* @returns {{ applied: number, conflicts: Array, failures: Array, committed: Array }}
|
|
219
|
+
* applied - number of decisions/memories actually committed (archive counts
|
|
220
|
+
* each archived memory as one, merge/conflict/update count one).
|
|
221
|
+
* conflicts - decisions skipped because a target changed since the snapshot.
|
|
222
|
+
* failures - decisions that threw mid-transaction (fully rolled back).
|
|
223
|
+
* committed - the decisions that actually landed, for outcome/receipt based
|
|
224
|
+
* on real committed sub-steps rather than the raw LLM list.
|
|
225
|
+
*/
|
|
226
|
+
export function applyDecisions(decisions, service, logger = null, snapshot = null, config = {}) {
|
|
227
|
+
let applied = 0;
|
|
228
|
+
const conflicts = [];
|
|
229
|
+
const failures = [];
|
|
230
|
+
const committed = [];
|
|
231
|
+
for (const [i, d] of decisions.entries()) {
|
|
232
|
+
try {
|
|
233
|
+
// keep is a confirmed no-op: it commits nothing but still records the
|
|
234
|
+
// per-id disposition so the outcome covers every snapshot memory.
|
|
235
|
+
if (d.action === "keep") {
|
|
236
|
+
committed.push({ action: "keep", ids: d.ids });
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const outcome = applyOne(d, service, snapshot, config);
|
|
240
|
+
if (outcome === "skipped") continue;
|
|
241
|
+
applied += outcome.applied;
|
|
242
|
+
committed.push(outcome.committed);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (error instanceof CasConflictError) {
|
|
245
|
+
conflicts.push({ index: i, action: d.action, ids: error.ids, reason: error.message });
|
|
246
|
+
logger?.warn?.(`dsh-mneme dream: ${error.message}`);
|
|
247
|
+
} else {
|
|
248
|
+
failures.push({ index: i, action: d.action, ids: decisionIds(d), reason: error.message });
|
|
249
|
+
logger?.warn?.(`dsh-mneme dream: failed to apply ${d.action} at index ${i}: ${error.message}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return { applied, conflicts, failures, committed };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function applyOne(d, service, snapshot, config = {}) {
|
|
257
|
+
switch (d.action) {
|
|
258
|
+
case "archive": return applyArchive(d, service, snapshot);
|
|
259
|
+
case "merge": return applyMerge(d, service, snapshot, config);
|
|
260
|
+
case "conflict": return applyConflict(d, service, snapshot, config);
|
|
261
|
+
case "create": return applyCreate(d, service, config);
|
|
262
|
+
default: return applyUpdate(d, service, snapshot, config);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Highest-epistemic-priority UNARCHIVED id among `ids` (ties break toward
|
|
268
|
+
* `preferred`). Archived memories are never eligible keepers — promoting one
|
|
269
|
+
* would demote the real keepSource to a source and then hit the archived-keeper
|
|
270
|
+
* guard in applyMerge, silently skipping the whole merge. When `preferred`
|
|
271
|
+
* itself is archived (or missing), fall back to any unarchived candidate.
|
|
272
|
+
*/
|
|
273
|
+
function pickBestKeeper(ids, preferred, service) {
|
|
274
|
+
let best = null;
|
|
275
|
+
let bestP = -1;
|
|
276
|
+
for (const id of ids) {
|
|
277
|
+
const mem = service.getById(id);
|
|
278
|
+
if (!mem || mem.archived) continue; // archived/missing: ineligible keeper
|
|
279
|
+
const p = EPISTEMIC_PRIORITY[mem.epistemic_status] ?? 0;
|
|
280
|
+
if (p > bestP || (p === bestP && id === preferred)) {
|
|
281
|
+
bestP = p;
|
|
282
|
+
best = id;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return best ?? preferred;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Mint a fresh memory (pattern discovery). No existing target, so no CAS guard.
|
|
290
|
+
* Evidence ids ride in the content so a pattern stays traceable to its source
|
|
291
|
+
* memories. saveWithDedupe dedupes identical mints (idempotent replay-safe).
|
|
292
|
+
*/
|
|
293
|
+
function applyCreate(d, service, config = {}) {
|
|
294
|
+
const title = String(d.title ?? "").trim();
|
|
295
|
+
const content = String(d.content ?? "").trim();
|
|
296
|
+
const importance = Number.isInteger(d.importance) ? d.importance : 3;
|
|
297
|
+
const type = typeof d.type === "string" ? d.type : "pattern";
|
|
298
|
+
const evidence = Array.isArray(d.evidence)
|
|
299
|
+
? d.evidence.filter((id) => typeof id === "string")
|
|
300
|
+
: [];
|
|
301
|
+
const body = evidence.length > 0
|
|
302
|
+
? `${content}\n\n[证据: ${evidence.join(", ")}]`
|
|
303
|
+
: content;
|
|
304
|
+
const created = service.saveWithDedupe({ type, title, content: body, importance });
|
|
305
|
+
const memory = created?.memory;
|
|
306
|
+
if (!memory) return "skipped"; // deduped/subsumed: nothing minted, clean no-op
|
|
307
|
+
return { applied: 1, committed: { action: "create", id: memory.id, type } };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function applyArchive(d, service, snapshot) {
|
|
311
|
+
const targets = d.ids.filter((id) => {
|
|
312
|
+
const mem = service.getById(id);
|
|
313
|
+
return mem && !mem.archived; // existing, not-yet-archived rows only
|
|
314
|
+
});
|
|
315
|
+
if (targets.length === 0) return "skipped"; // all already archived: idempotent replay
|
|
316
|
+
service.transaction(() => {
|
|
317
|
+
casGuard(service, snapshot, d.ids);
|
|
318
|
+
for (const id of d.ids) {
|
|
319
|
+
const mem = service.getById(id);
|
|
320
|
+
if (mem && !mem.archived) service.setArchived(id, true);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
return { applied: targets.length, committed: { action: "archive", ids: targets } };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function applyMerge(d, service, snapshot, config = {}) {
|
|
327
|
+
// Epistemic trust (v0.4.5): when enabled, prefer an observation keeper over a
|
|
328
|
+
// subjective/inferred one. Mutating the decision keeps the receipt + committed
|
|
329
|
+
// record aligned with the actual keeper.
|
|
330
|
+
if (config.trustEpistemicWeighting === true) {
|
|
331
|
+
const best = pickBestKeeper(d.ids, d.keepSource, service);
|
|
332
|
+
if (best && best !== d.keepSource) d.keepSource = best;
|
|
333
|
+
}
|
|
334
|
+
const sources = d.ids.filter((id) => id !== d.keepSource);
|
|
335
|
+
// Idempotent replay: if every other source is already archived, this merge
|
|
336
|
+
// already landed — skip so a replayed/concurrent decision never double-counts
|
|
337
|
+
// or re-applies (guard against duplicate merges).
|
|
338
|
+
if (sources.every((id) => service.getById(id)?.archived)) return "skipped";
|
|
339
|
+
service.transaction(() => {
|
|
340
|
+
casGuard(service, snapshot, d.ids);
|
|
341
|
+
const keeper = service.getById(d.keepSource);
|
|
342
|
+
if (!keeper || keeper.archived) return; // missing keeper: no write, still a clean commit
|
|
343
|
+
service.update(d.keepSource, {
|
|
344
|
+
title: d.title,
|
|
345
|
+
content: d.content,
|
|
346
|
+
importance: d.importance ?? Math.max(keeper.importance, ...d.ids.map((id) => service.getById(id)?.importance ?? 1))
|
|
347
|
+
});
|
|
348
|
+
for (const id of sources) {
|
|
349
|
+
const mem = service.getById(id);
|
|
350
|
+
if (mem && !mem.archived) service.setArchived(id, true);
|
|
351
|
+
}
|
|
352
|
+
// 4.3.2 迁移实体关联(opt-in):将 loser(source)记忆关联的 entity_attrs 的
|
|
353
|
+
// memory_id 迁移到 keeper;keeper 已有同 entity+key 的当前属性时 loser 行被
|
|
354
|
+
// 失效。单个 source 迁移失败只告警,绝不能导致整个 merge 事务回滚(fail-safe)。
|
|
355
|
+
if (config.entityExtractionEnabled && typeof service.migrateAttrsToMemory === "function") {
|
|
356
|
+
for (const id of sources) {
|
|
357
|
+
try {
|
|
358
|
+
service.migrateAttrsToMemory(id, d.keepSource, new Date().toISOString());
|
|
359
|
+
} catch (error) {
|
|
360
|
+
logger?.warn?.(`dsh-mneme dream: failed to migrate attrs from ${id} to ${d.keepSource}: ${error.message}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
return {
|
|
366
|
+
applied: 1,
|
|
367
|
+
committed: { action: "merge", ids: d.ids, keepSource: d.keepSource, title: d.title, content: d.content, importance: d.importance, count_before: d.ids.length, count_after: 1 }
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function applyConflict(d, service, snapshot, config = {}) {
|
|
372
|
+
// Epistemic trust (v0.4.5): when enabled, the observation side of a conflict
|
|
373
|
+
// is preferred as winner over a subjective/inferred one.
|
|
374
|
+
if (config.trustEpistemicWeighting === true) {
|
|
375
|
+
const pw = EPISTEMIC_PRIORITY[service.getById(d.winner)?.epistemic_status] ?? 0;
|
|
376
|
+
const pl = EPISTEMIC_PRIORITY[service.getById(d.loser)?.epistemic_status] ?? 0;
|
|
377
|
+
if (pl > pw) [d.winner, d.loser] = [d.loser, d.winner];
|
|
378
|
+
}
|
|
379
|
+
const winner = service.getById(d.winner);
|
|
380
|
+
const loser = service.getById(d.loser);
|
|
381
|
+
if (!winner || !loser) return "skipped";
|
|
382
|
+
// Idempotent replay: an already-archived loser means the conflict was already
|
|
383
|
+
// adjudicated — skip so the provenance note is never re-appended and the loser
|
|
384
|
+
// is not re-archived.
|
|
385
|
+
if (loser.archived) return "skipped";
|
|
386
|
+
service.transaction(() => {
|
|
387
|
+
casGuard(service, snapshot, [d.winner, d.loser]);
|
|
388
|
+
const winnerNow = service.getById(d.winner);
|
|
389
|
+
const loserNow = service.getById(d.loser);
|
|
390
|
+
if (!winnerNow || !loserNow || loserNow.archived) return;
|
|
391
|
+
service.update(d.winner, {
|
|
392
|
+
content: `${winnerNow.content}\n\n(已否决旧信息:${[...loserNow.content].slice(0, 100).join("")})`
|
|
393
|
+
});
|
|
394
|
+
service.setArchived(d.loser, true);
|
|
395
|
+
});
|
|
396
|
+
return { applied: 1, committed: { action: "conflict", winner: d.winner, loser: d.loser, count_before: 2, count_after: 1 } };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function applyUpdate(d, service, snapshot, config = {}) {
|
|
400
|
+
const id = d.ids[0];
|
|
401
|
+
const mem = service.getById(id);
|
|
402
|
+
if (!mem || mem.archived) return "skipped";
|
|
403
|
+
// 幂等检查:如果字段已与目标一致则跳过
|
|
404
|
+
const same = (d.title === undefined || d.title === mem.title)
|
|
405
|
+
&& (d.content === undefined || d.content === mem.content)
|
|
406
|
+
&& (d.importance === undefined || d.importance === mem.importance);
|
|
407
|
+
if (same) return "skipped";
|
|
408
|
+
service.transaction(() => {
|
|
409
|
+
casGuard(service, snapshot, [id]);
|
|
410
|
+
const cur = service.getById(id);
|
|
411
|
+
if (!cur || cur.archived) return;
|
|
412
|
+
service.update(id, {
|
|
413
|
+
title: d.title ?? cur.title,
|
|
414
|
+
content: d.content ?? cur.content,
|
|
415
|
+
importance: d.importance ?? cur.importance
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
// 4.3.1 supersedes 关系(opt-in):事务提交成功后,为该记忆关联的每条实体属性
|
|
419
|
+
// 建立自引用 supersedes 关系,表示"此属性版本已被替代"。仅记录、绝不阻断主流程
|
|
420
|
+
// (fail-safe):记录失败只告警,update 本身照常生效。
|
|
421
|
+
if (config.entityExtractionEnabled && typeof service.saveRelation === "function" && typeof service.getAttrsByMemory === "function") {
|
|
422
|
+
try {
|
|
423
|
+
const oldAttrs = service.getAttrsByMemory(id);
|
|
424
|
+
for (const attr of oldAttrs) {
|
|
425
|
+
if (!attr.entity_id) continue;
|
|
426
|
+
service.saveRelation({
|
|
427
|
+
from_entity: attr.entity_id,
|
|
428
|
+
to_entity: attr.entity_id,
|
|
429
|
+
relation_type: "supersedes",
|
|
430
|
+
memory_id: id,
|
|
431
|
+
metadata: JSON.stringify({ attr_key: attr.attr_key, old_value: attr.attr_value })
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
} catch (error) {
|
|
435
|
+
logger?.warn?.(`dsh-mneme dream: failed to record supersedes relations for ${id}: ${error.message}`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return { applied: 1, committed: { action: "update", ids: [id], title: d.title, content: d.content, importance: d.importance, count_before: 1, count_after: 1 } };
|
|
439
|
+
}
|