@modusensus/dsh-mneme 0.9.4 → 0.10.0
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 +57 -10
- package/bin/cli.mjs +215 -2
- package/docs/CONFIGURATION.md +25 -2
- package/docs/DAEMON.md +13 -2
- package/docs/DISTILL.md +150 -4
- package/docs/SHARE.md +552 -0
- package/docs/SLEEP.md +21 -7
- package/docs/STORAGE.md +2 -1
- package/lib/api-standalone.js +294 -8
- package/lib/api.js +217 -3
- package/lib/client.js +919 -39
- package/lib/config.js +33 -1
- package/lib/continuity.js +11 -3
- package/lib/document.js +15 -2
- package/lib/dream/decisions.js +176 -30
- package/lib/dream/sleep.js +530 -94
- package/lib/dream.js +174 -21
- package/lib/fence.js +24 -0
- package/lib/index.js +15 -6
- package/lib/inject.js +9 -4
- package/lib/lang.js +38 -16
- package/lib/memory-source.js +9 -0
- package/lib/organize.js +5 -1
- package/lib/pipeline-nudge-auto.js +3 -0
- package/lib/pipeline-nudge.js +41 -8
- package/lib/pipeline-runner.js +15 -0
- package/lib/pipeline.js +62 -1
- package/lib/pre-inject-gate.js +14 -0
- package/lib/profile-extract.js +4 -1
- package/lib/provenance-http.js +251 -0
- package/lib/serve.js +13 -0
- package/lib/service.js +97 -4
- package/lib/session-gate.js +91 -0
- package/lib/settings.js +13 -1
- package/lib/share-http.js +281 -0
- package/lib/share-import.js +234 -0
- package/lib/share.js +335 -0
- package/lib/store.js +797 -28
- package/lib/summarize.js +209 -23
- package/lib/tools.js +26 -6
- package/package.json +4 -2
- package/src/api-standalone.js +294 -8
- package/src/api.js +217 -3
- package/src/config.js +33 -1
- package/src/continuity.js +11 -3
- package/src/document.js +15 -2
- package/src/dream/decisions.js +176 -30
- package/src/dream/sleep.js +530 -94
- package/src/dream.js +174 -21
- package/src/fence.js +24 -0
- package/src/index.js +15 -6
- package/src/inject.js +9 -4
- package/src/lang.js +38 -16
- package/src/memory-source.js +9 -0
- package/src/organize.js +5 -1
- package/src/pipeline-nudge-auto.js +3 -0
- package/src/pipeline-nudge.js +41 -8
- package/src/pipeline-runner.js +15 -0
- package/src/pipeline.js +62 -1
- package/src/pre-inject-gate.js +14 -0
- package/src/profile-extract.js +4 -1
- package/src/provenance-http.js +251 -0
- package/src/serve.js +13 -0
- package/src/service.js +97 -4
- package/src/session-gate.js +91 -0
- package/src/settings.js +13 -1
- package/src/share-http.js +281 -0
- package/src/share-import.js +234 -0
- package/src/share.js +335 -0
- package/src/store.js +797 -28
- package/src/summarize.js +209 -23
- package/src/tools.js +26 -6
- package/test/api.test.js +523 -6
- package/test/audit.test.js +2 -2
- package/test/cli-receive.test.js +390 -0
- package/test/cli-share.test.js +437 -0
- package/test/client-share.test.js +225 -0
- package/test/client.test.js +354 -0
- package/test/config.test.js +8 -0
- package/test/conflict-reviewed.test.js +79 -0
- package/test/continuity.test.js +29 -1
- package/test/dedupe-candidate-window.test.js +180 -0
- package/test/dependency-overrides.test.js +78 -0
- package/test/differentiate-feedback.test.js +248 -0
- package/test/dream-candidate-hybrid.test.js +2 -2
- package/test/dream-decision-evidence.test.js +314 -0
- package/test/dream-fence.test.js +207 -0
- package/test/dream.test.js +87 -5
- package/test/helpers/dream-mock.js +6 -2
- package/test/inject-pools.test.js +2 -1
- package/test/llm-message-source.test.js +1 -1
- package/test/mint-source.test.js +84 -1
- package/test/pipeline-nudge-auto.test.js +26 -2
- package/test/pipeline-nudge.test.js +87 -0
- package/test/pipeline-r1r2.test.js +129 -0
- package/test/pipeline-runner.test.js +26 -0
- package/test/policy-epoch.test.js +2 -2
- package/test/prompt-stance.test.js +128 -0
- package/test/provenance-http.test.js +367 -0
- package/test/readme-claims.test.js +45 -0
- package/test/reasoning-effort.test.js +10 -10
- package/test/release-docs.test.js +7 -1
- package/test/scope-candidates.test.js +7 -4
- package/test/scope-state.test.js +130 -1
- package/test/security-doc.test.js +43 -4
- package/test/serve.test.js +62 -0
- package/test/session-gate.test.js +264 -0
- package/test/settings.test.js +4 -0
- package/test/share-foreign-rows.test.js +144 -0
- package/test/share-http.test.js +144 -0
- package/test/share-import.test.js +577 -0
- package/test/share-imported-guard.test.js +311 -0
- package/test/share-imported-identity.test.js +111 -0
- package/test/share-pack.test.js +511 -0
- package/test/share-receive-http.test.js +756 -0
- package/test/share-routes.test.js +337 -0
- package/test/share-store.test.js +133 -0
- package/test/share-validate.test.js +101 -0
- package/test/sleep-action-set.test.js +16 -11
- package/test/sleep-catchup.test.js +315 -0
- package/test/sleep-failure-backoff.test.js +156 -0
- package/test/sleep.test.js +293 -0
- package/test/standalone-api.test.js +574 -5
- package/test/store.test.js +220 -0
- package/test/summarize-parse-diagnostics.test.js +75 -0
- package/test/summarize.test.js +187 -2
package/src/dream/decisions.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { STR, langOf } from "../lang.js";
|
|
2
|
+
import { contentHashOf } from "../content-hash.js";
|
|
2
3
|
|
|
3
4
|
// Issue #126:sleepActionSet="full" 时新增两个动作——supersede(演进:新版取代
|
|
4
|
-
// 旧版)与 differentiate
|
|
5
|
-
//
|
|
5
|
+
// 旧版)与 differentiate(互补:两条都留,各自保留原本的正文)。两者的校验与执行
|
|
6
|
+
// 都复用既有 merge/conflict 的机制(winner/loser、casGuard、transaction、receipt)。
|
|
7
|
+
// Issue #533 起 differentiate 不再改写正文,只落裁决账本——见 applyDifferentiate。
|
|
6
8
|
// 调用方(sleep)按档位派生「本档允许的动作集合」时需要读它,故导出。
|
|
7
9
|
export const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update", "create", "supersede", "differentiate"]);
|
|
8
10
|
|
|
@@ -19,6 +21,78 @@ const ARCHIVE_GUARDED_TYPES = new Set(["preference", "pattern", "rejected_soluti
|
|
|
19
21
|
// 「重复 / 过时」类理由的关键词(中英双语)。命中才允许归档长保留类型;
|
|
20
22
|
// 词表宁可放宽也不误拦——护栏只跳过单条并落明细,不整单拒绝。
|
|
21
23
|
const ARCHIVE_RATIONALE_RE = /重复|过时|陈旧|过期|已被取代|被取代|冗余|不再使用|旧版|废弃|stale|outdated|obsolete|dup(?:licate|licat)?|superseded|redundant|deprecated|expired|replaced/i;
|
|
24
|
+
// Issue #522 评审:上面那张词表只认关键词,不认否定——「并不重复,只是主题相近」
|
|
25
|
+
// 与 "not a duplicate" 都会命中「重复」/"duplicate" 子串。archive 护栏从 #104 起
|
|
26
|
+
// 就带着这个洞(既有行为,改它会扩大范围,先记在这里),但 #521 给 merge 加的
|
|
27
|
+
// 逃生门是本 PR 新开的口子,必须自己堵上:否定表述一票否决。第二轮评审又指出
|
|
28
|
+
// 词表里「过时 / stale / outdated」同样是钥匙,只挡「重复」等于留了半边门——
|
|
29
|
+
// 「并不过时,只是主题相近」照样能换到合并许可,所以否定词按整张词表铺开。
|
|
30
|
+
const NEGATED_RATIONALE_RE = /(?:不|并不|不是|非)\s*(?:重复|过时|陈旧|过期|已被取代|被取代|冗余|旧版|废弃)|\b(?:not|no|isn't|is not|never)\s+(?:a\s+|an\s+)?(?:duplicat\w*|stale|outdated|obsolete|superseded|redundant|deprecated|expired|replaced)/i;
|
|
31
|
+
|
|
32
|
+
// Issue #521:把 skipped[].error 归一化成可分组的原因。原样字符串不能直接
|
|
33
|
+
// GROUP BY——前缀带 decision[N]: 序号,同一种原因会被拆成 N 组(#521 实测 280
|
|
34
|
+
// 条被拆成 20 组),面板上看不出「一整类动作 100% 失败」。这里只剥三样东西:
|
|
35
|
+
// 序号前缀、模型可控的引号文本、具体类型集合,其余措辞保持原样,好让人一眼
|
|
36
|
+
// 认出是哪个判据。
|
|
37
|
+
export function normalizeSkipReason(error) {
|
|
38
|
+
// 一条决策可能攒下多条错误,skipped[].error 用 "; " 把它们拼成一句(见下方
|
|
39
|
+
// skipped.push)。所以先按 decision[N]: 的边界切开、逐段归一化再拼回去——
|
|
40
|
+
// 原先的正则 ^ 锚定,只剥得掉第一个前缀,后面的 decision[1]: 会留在字符串
|
|
41
|
+
// 中间,聚合时又被拆成多组(#522 评审指出)。
|
|
42
|
+
// 抹引号必须排在切分之前:模型写的理由原文里可能正好含 "; decision[0]: "
|
|
43
|
+
// (引文、邮箱都算),先切分就会把这段自由文本切成独立的一段,而那一段不带
|
|
44
|
+
// 任何可识别的边界,后面对它无从下手。先抹掉引号里的内容,切分就只看得到
|
|
45
|
+
// 我们自己拼进去的分隔符。
|
|
46
|
+
return maskModelText(String(error ?? ""))
|
|
47
|
+
.split(/;\s*(?=decision\[\d+\]:\s*)/)
|
|
48
|
+
.map(normalizeSkipSegment)
|
|
49
|
+
.join("; ")
|
|
50
|
+
.trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 引号内的内容一律是模型可控的:id、action 名、理由原文都由 JSON.stringify
|
|
54
|
+
// 加引号,所以「引号内 = 不可信」是这里唯一的判据。成对的那段整段抹成占位符;
|
|
55
|
+
// 万一剩下一个孤立引号(旧版把理由截到 60 字再拼进 error,可能正好断在字符串
|
|
56
|
+
// 中间),从那里砍到结尾——宁可少一句尾巴,也不让记忆正文随 open 路由出网关。
|
|
57
|
+
// 刻意不按「, got: 」这类分隔符猜边界(#522 三轮评审):分隔符本身可能出现在
|
|
58
|
+
// id 里(unknown id "a@b.c, got: x"),猜错就会把引号截断,脱敏反而失效。
|
|
59
|
+
function maskModelText(text) {
|
|
60
|
+
return String(text ?? "")
|
|
61
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, "\u0000")
|
|
62
|
+
.replace(/"(?:[\s\S])*$/, "\u0000")
|
|
63
|
+
.replace(/\u0000/g, '"…"');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 单段归一化:剥序号前缀、丢掉已经抹空的自由文本尾巴、把具体类型集合抹成占位符。
|
|
67
|
+
function normalizeSkipSegment(segment) {
|
|
68
|
+
return String(segment ?? "")
|
|
69
|
+
.replace(/^decision\[\d+\]:\s*/, "")
|
|
70
|
+
// 抹完之后「, got: "…"」是个固定字面量(maskModelText 的产物),可以整段
|
|
71
|
+
// 砍掉:它只说明「校验器当时回了一句理由」,内容已经不在字符串里了。
|
|
72
|
+
.replace(/,\s*got:\s*"…"$/, "")
|
|
73
|
+
// 类型集合同理,但单个类型名保留——「被拦的是哪一类」本身是有效信息
|
|
74
|
+
.replace(/\(([a-z_]+(?:,\s*[a-z_]+)+)\)/g, "(…)")
|
|
75
|
+
.trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Issue #521:按归一化原因聚合 skipped,供面板直接显示。按 (action, reason)
|
|
79
|
+
// 二元组计数——同一句原因在 merge 和 archive 上是两回事,混在一起会误导。
|
|
80
|
+
export function summarizeSkipped(skipped) {
|
|
81
|
+
const buckets = new Map();
|
|
82
|
+
for (const item of Array.isArray(skipped) ? skipped : []) {
|
|
83
|
+
const reason = normalizeSkipReason(item?.error);
|
|
84
|
+
if (!reason) continue;
|
|
85
|
+
// #522 评审:action 也走白名单。这条聚合结果会随 open 路由出网关,放任意字符串
|
|
86
|
+
// 过去等于让上游的脏数据(或将来某个新字段)自己决定响应形状;不在枚举里的一律
|
|
87
|
+
// 归成空串,面板少显示一个动作标签,比显示一段来路不明的文本安全。
|
|
88
|
+
const action = ACTIONS.has(item?.action) ? item.action : "";
|
|
89
|
+
const key = action + "\u0000" + reason;
|
|
90
|
+
const bucket = buckets.get(key);
|
|
91
|
+
if (bucket) bucket.count += 1;
|
|
92
|
+
else buckets.set(key, { action, reason, count: 1 });
|
|
93
|
+
}
|
|
94
|
+
return [...buckets.values()].sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason));
|
|
95
|
+
}
|
|
22
96
|
|
|
23
97
|
/**
|
|
24
98
|
* Validate a dream decision list against a snapshot of eligible memories.
|
|
@@ -107,9 +181,10 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
107
181
|
local.push(`${at}: ${d.action} needs distinct winner and loser`);
|
|
108
182
|
}
|
|
109
183
|
} else if (d.action === "differentiate") {
|
|
110
|
-
// Issue #126
|
|
111
|
-
//
|
|
112
|
-
//
|
|
184
|
+
// Issue #126:互补型——两条都留下。ids 必须 ≥2(随后走下方通用 id 校验:
|
|
185
|
+
// unknown / archived / summary / 重复 claim)。distinctions 仍然必填:#533 之后
|
|
186
|
+
// 它不再写进正文,但仍是「模型确实做过分类」的唯一凭据,落进裁决账本供审计
|
|
187
|
+
// (空数组意味着模型只是偷懒输出了一条动作名,不该被记成已裁决)。
|
|
113
188
|
if (!Array.isArray(d.ids) || d.ids.length < 2) {
|
|
114
189
|
local.push(`${at}: differentiate needs at least two ids`);
|
|
115
190
|
} else if (!Array.isArray(d.distinctions) || !d.distinctions.some((s) => typeof s === "string" && s.trim())) {
|
|
@@ -149,7 +224,7 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
149
224
|
if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type);
|
|
150
225
|
}
|
|
151
226
|
if (guardedTypes.size > 0 && !ARCHIVE_RATIONALE_RE.test(String(d.reason ?? ""))) {
|
|
152
|
-
local.push(`${at}: archive of long-retention type(s) ${[...guardedTypes].join(", ")} needs a duplicate/outdated rationale (重复/过时), got: ${JSON.stringify(String(d.reason ?? "")
|
|
227
|
+
local.push(`${at}: archive of long-retention type(s) ${[...guardedTypes].join(", ")} needs a duplicate/outdated rationale (重复/过时), got: ${JSON.stringify(String(d.reason ?? "").slice(0, 60))}`);
|
|
153
228
|
}
|
|
154
229
|
}
|
|
155
230
|
if (local.length === 0 && d?.action === "update") {
|
|
@@ -222,8 +297,21 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
222
297
|
const mem = snapshot.get(id);
|
|
223
298
|
if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type);
|
|
224
299
|
}
|
|
225
|
-
|
|
226
|
-
|
|
300
|
+
// Issue #521:与上方 archive 护栏对齐——archive 早就允许「带重复/过时
|
|
301
|
+
// 理由」的长保留类型通过,merge 却一律拦死,于是一份真重复的
|
|
302
|
+
// preference 永远合不掉,而提示词还在催模型合并它们。实测(#521)46 轮
|
|
303
|
+
// 巩固里 280 条 merge 决策全被这条拦下、merge 恒为 0,模型每轮把同一
|
|
304
|
+
// 簇条目再提一次,满载 token 空转。逃生门条件与 archive 护栏同一条词表
|
|
305
|
+
// 且要求同类型(跨类型合并本就另受 allowCrossTypeMerge 约束,逃生门
|
|
306
|
+
// 不跟着放宽)。reason 只写「主题相近」不会命中词表——这是故意的:
|
|
307
|
+
// 相近不等于重复,只有确属重复/过时才值得牺牲两条独立表述。
|
|
308
|
+
const sameType = new Set(d.ids.map((id) => snapshot.get(id)?.type)).size === 1;
|
|
309
|
+
const reasonText = String(d.reason ?? "");
|
|
310
|
+
// #522 评审:词表命中还不够——「并不重复」「not a duplicate」也含关键词,
|
|
311
|
+
// 放它们过闸等于让模型用一句否认换来合并许可。否定表述直接判不重复。
|
|
312
|
+
const duplicated = !NEGATED_RATIONALE_RE.test(reasonText) && ARCHIVE_RATIONALE_RE.test(reasonText);
|
|
313
|
+
if (guardedTypes.size > 0 && !(sameType && duplicated)) {
|
|
314
|
+
local.push(`${at}: merge of long-retention type(s) ${[...guardedTypes].join(", ")} is blocked by dreamMergeGuard (needs a duplicate/outdated rationale, 重复/过时)`);
|
|
227
315
|
}
|
|
228
316
|
}
|
|
229
317
|
}
|
|
@@ -233,7 +321,22 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
233
321
|
// 单条非法 → 跳过该决策,不 claim id(其目标记忆留给其它合法决策/隐式
|
|
234
322
|
// keep),并记录到 skipped 供调用方日志/审计。信息性跳过绝不写入全局
|
|
235
323
|
// errors,否则会误触发下方的整单拒绝。
|
|
236
|
-
|
|
324
|
+
//
|
|
325
|
+
// #534:除四键骨架外补三个「模型到底给没给」的布尔,把 skipped 从
|
|
326
|
+
// 「error 字符串」变成可计数的形状。作者实测 19 轮 17 轮整轮无效,但
|
|
327
|
+
// skipped 里只有一句错误串,事后无法判断模型是漏字段(merge 缺
|
|
328
|
+
// title/content)、还是字段给了但值不合规(has_* 为 true 仍报错)。
|
|
329
|
+
// 只加布尔不加原文:原文进 llm_audit_logs.metadata 与 dream_runs.raw_output,
|
|
330
|
+
// 这里保持每条记录足够小(skipped 会按轮累积进一行 JSON)。
|
|
331
|
+
skipped.push({
|
|
332
|
+
index,
|
|
333
|
+
action: d?.action,
|
|
334
|
+
ids,
|
|
335
|
+
error: local.join("; "),
|
|
336
|
+
has_keepSource: d?.keepSource !== undefined && d?.keepSource !== null,
|
|
337
|
+
has_title: typeof d?.title === "string" && d.title.trim().length > 0,
|
|
338
|
+
has_content: typeof d?.content === "string" && d.content.trim().length > 0
|
|
339
|
+
});
|
|
237
340
|
} else {
|
|
238
341
|
errors.push(...local);
|
|
239
342
|
}
|
|
@@ -422,7 +525,7 @@ export function applyDecisions(decisions, service, logger = null, snapshot = nul
|
|
|
422
525
|
committed.push({ action: "keep", ids: d.ids });
|
|
423
526
|
continue;
|
|
424
527
|
}
|
|
425
|
-
const outcome = applyOne(d, service, snapshot, config);
|
|
528
|
+
const outcome = applyOne(d, service, snapshot, config, logger);
|
|
426
529
|
if (outcome === "skipped") continue;
|
|
427
530
|
applied += outcome.applied;
|
|
428
531
|
committed.push(outcome.committed);
|
|
@@ -439,14 +542,14 @@ export function applyDecisions(decisions, service, logger = null, snapshot = nul
|
|
|
439
542
|
return { applied, conflicts, failures, committed };
|
|
440
543
|
}
|
|
441
544
|
|
|
442
|
-
function applyOne(d, service, snapshot, config = {}) {
|
|
545
|
+
function applyOne(d, service, snapshot, config = {}, logger = null) {
|
|
443
546
|
switch (d.action) {
|
|
444
547
|
case "archive": return applyArchive(d, service, snapshot);
|
|
445
548
|
case "merge": return applyMerge(d, service, snapshot, config);
|
|
446
549
|
case "conflict": return applyConflict(d, service, snapshot, config);
|
|
447
550
|
case "supersede": return applySupersede(d, service, snapshot, config);
|
|
448
551
|
case "differentiate": return applyDifferentiate(d, service, snapshot, config);
|
|
449
|
-
case "create": return applyCreate(d, service, config);
|
|
552
|
+
case "create": return applyCreate(d, service, config, logger);
|
|
450
553
|
default: return applyUpdate(d, service, snapshot, config);
|
|
451
554
|
}
|
|
452
555
|
}
|
|
@@ -478,7 +581,7 @@ function pickBestKeeper(ids, preferred, service) {
|
|
|
478
581
|
* Evidence ids ride in the content so a pattern stays traceable to its source
|
|
479
582
|
* memories. saveWithDedupe dedupes identical mints (idempotent replay-safe).
|
|
480
583
|
*/
|
|
481
|
-
function applyCreate(d, service, config = {}) {
|
|
584
|
+
function applyCreate(d, service, config = {}, logger = null) {
|
|
482
585
|
const language = langOf(config);
|
|
483
586
|
const title = String(d.title ?? "").trim();
|
|
484
587
|
const content = String(d.content ?? "").trim();
|
|
@@ -503,6 +606,22 @@ function applyCreate(d, service, config = {}) {
|
|
|
503
606
|
});
|
|
504
607
|
const memory = created?.memory;
|
|
505
608
|
if (!memory) return "skipped"; // deduped/subsumed: nothing minted, clean no-op
|
|
609
|
+
// #512:这条记忆出生自哪一轮。只记 create——merge/conflict/update/supersede 对
|
|
610
|
+
// 既有行的改动已由 receipt_chain 按 (run_id, record_id) 记账,再造一份就是第二套账。
|
|
611
|
+
// 记账失败不许拖垮这次铸造:归因是审计,记忆已经落盘了。
|
|
612
|
+
try {
|
|
613
|
+
if (typeof service?.saveMemoryMint === "function" && config?.runId) {
|
|
614
|
+
service.saveMemoryMint({
|
|
615
|
+
memoryId: memory.id,
|
|
616
|
+
runId: config.runId,
|
|
617
|
+
runType: config.mintSource === "sleep" ? "sleep" : "dream",
|
|
618
|
+
triggerSource: config.mintSource === "sleep" ? "sleep" : "autoDream",
|
|
619
|
+
createdAt: new Date().toISOString()
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
} catch (mintError) {
|
|
623
|
+
logger?.warn?.(`dsh-mneme dream: failed to record memory mint: ${String(mintError?.message ?? mintError)}`);
|
|
624
|
+
}
|
|
506
625
|
return { applied: 1, committed: { action: "create", id: memory.id, type } };
|
|
507
626
|
}
|
|
508
627
|
|
|
@@ -631,35 +750,62 @@ function applySupersede(d, service, snapshot, config = {}) {
|
|
|
631
750
|
|
|
632
751
|
/**
|
|
633
752
|
* differentiate(Issue #126,sleepActionSet="full"):互补型重复——两条各自成立、
|
|
634
|
-
*
|
|
635
|
-
*
|
|
753
|
+
* 覆盖不同侧面,不该判输赢。双方都保留(不归档),**正文一个字节都不动**。
|
|
754
|
+
*
|
|
755
|
+
* 为什么不动正文(Issue #533):#126 的原设计是「双方各追加同一段差异注记,注记
|
|
756
|
+
* 进正文 → 进注入与 embedding → 下一轮就不会再被判成重复」。离线复现证伪了这个
|
|
757
|
+
* 假设——两侧共享同一段后缀会让两条向量**同向靠拢**,余弦一轮比一轮高(0.876 →
|
|
758
|
+
* 0.960 只用了 5 轮,把注记截到 0 字照样往上爬,因为推高相似度的是共享前缀本身而
|
|
759
|
+
* 不是注记长短),于是同一对下一轮又被判成重复、又追加一段,实测单条堆到 19 段。
|
|
760
|
+
* 现在的裁决落在 differentiated_pairs 账本(src/store.js)里:候选构造阶段读它,
|
|
761
|
+
* 已裁决且两侧正文都没改过就不再送模型,正文保持干净。
|
|
636
762
|
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
763
|
+
* 幂等三层,从便宜到贵:
|
|
764
|
+
* 1. 一侧已归档 → skipped(缺一条就没什么可区分的);
|
|
765
|
+
* 2. 存量兼容:两侧正文都还带着旧版注记块 → skipped(升级不清账);
|
|
766
|
+
* 3. 账本命中且两侧 content hash 与记账时一致 → skipped(真正的幂等重放)。
|
|
767
|
+
* 任一侧后来被改写 → hash 对不上 → 重新裁决,宁可再判一次也不静音真的演化。
|
|
639
768
|
*/
|
|
640
769
|
function applyDifferentiate(d, service, snapshot, config = {}) {
|
|
641
|
-
const language = langOf(config);
|
|
642
770
|
const ids = Array.isArray(d.ids) ? d.ids : [];
|
|
643
|
-
const notes = (Array.isArray(d.distinctions) ? d.distinctions : [])
|
|
644
|
-
.filter((s) => typeof s === "string" && s.trim())
|
|
645
|
-
.map((s) => s.trim());
|
|
646
|
-
const marker = STR.differentiatedMarker[language](notes);
|
|
647
771
|
const alive = ids.filter((id) => {
|
|
648
772
|
const m = service.getById(id);
|
|
649
773
|
return m && !m.archived;
|
|
650
774
|
});
|
|
651
775
|
if (alive.length < 2) return "skipped"; // 缺一条即已处理/不可用(幂等)
|
|
652
|
-
|
|
653
|
-
|
|
776
|
+
const hashes = new Map(alive.map((id) => [id, contentHashOf(service.getById(id))]));
|
|
777
|
+
const [a, b] = alive.slice(0, 2);
|
|
778
|
+
const record = typeof service.findDifferentiatedPair === "function"
|
|
779
|
+
? service.findDifferentiatedPair(a, b)
|
|
780
|
+
: undefined;
|
|
781
|
+
if (record) {
|
|
782
|
+
// 记账时 a/b 是按 id 排序存的,hash 也跟着换位,这里同样按排序取值比对。
|
|
783
|
+
const [ha, hb] = a <= b ? [hashes.get(a), hashes.get(b)] : [hashes.get(b), hashes.get(a)];
|
|
784
|
+
if (record.hash_a === ha && record.hash_b === hb) return "skipped";
|
|
785
|
+
} else {
|
|
786
|
+
// 存量库兼容:旧版本把注记写进了正文,那些行没有账本行。两侧都还带着注记块
|
|
787
|
+
// 才认定「已裁决过」——只有一侧有,说明另一侧后来被改写,应当重判。
|
|
788
|
+
const noted = alive.filter((id) => STR.differentiatedNoteRe.test(String(service.getById(id)?.content ?? "")));
|
|
789
|
+
if (noted.length >= 2) return "skipped";
|
|
654
790
|
}
|
|
791
|
+
// 账本缺席(人工确认路径的裸 service)就什么都不写:正文一字不动本来就是本动作
|
|
792
|
+
// 的全部副作用,没有账本就只剩「这条决策无处落地」,不是错误,也不是 applied。
|
|
793
|
+
if (typeof service.saveDifferentiatedPair !== "function") return "skipped";
|
|
655
794
|
service.transaction(() => {
|
|
656
795
|
casGuard(service, snapshot, ids);
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
796
|
+
const [ia, ib] = alive.slice(0, 2);
|
|
797
|
+
service.saveDifferentiatedPair({
|
|
798
|
+
run_id: config.runId,
|
|
799
|
+
memory_a: ia,
|
|
800
|
+
memory_b: ib,
|
|
801
|
+
// hash 取**执行时**的正文(不是送审时),与账本里下一次比对的口径同源。
|
|
802
|
+
hash_a: hashes.get(ia),
|
|
803
|
+
hash_b: hashes.get(ib),
|
|
804
|
+
// 模型给的差异点也落账:正文不再写注记,这些就是「当初为什么判互补」的唯一凭据。
|
|
805
|
+
distinctions: (Array.isArray(d.distinctions) ? d.distinctions : [])
|
|
806
|
+
.filter((x) => typeof x === "string" && x.trim())
|
|
807
|
+
.map((x) => x.trim())
|
|
808
|
+
});
|
|
663
809
|
});
|
|
664
810
|
return {
|
|
665
811
|
applied: 1,
|