@modusensus/dsh-mneme 0.7.31 → 0.7.32

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.
Files changed (86) hide show
  1. package/README.md +24 -8
  2. package/lib/api.js +79 -6
  3. package/lib/client.js +217 -30
  4. package/lib/commands.js +6 -2
  5. package/lib/config.js +80 -14
  6. package/lib/dream/decisions.js +147 -14
  7. package/lib/dream/sleep.js +84 -34
  8. package/lib/dream.js +250 -60
  9. package/lib/embedding.js +22 -2
  10. package/lib/index.js +68 -37
  11. package/lib/inject.js +16 -9
  12. package/lib/lang.js +156 -0
  13. package/lib/local-embedder.js +23 -9
  14. package/lib/mirror.js +17 -14
  15. package/lib/reranker.js +23 -8
  16. package/lib/runtime/adopt.js +176 -0
  17. package/lib/runtime/closure.js +172 -0
  18. package/lib/runtime/download.js +247 -0
  19. package/lib/runtime/layout.js +235 -0
  20. package/lib/runtime/loader.js +249 -0
  21. package/lib/runtime/provision.js +237 -0
  22. package/lib/runtime/tarball.js +130 -0
  23. package/lib/runtime/verify.js +191 -0
  24. package/lib/service.js +102 -8
  25. package/lib/settings.js +36 -2
  26. package/lib/store.js +26 -8
  27. package/lib/summarize.js +108 -35
  28. package/lib/tools.js +119 -0
  29. package/package.json +7 -2
  30. package/runtime-manifest.json +596 -0
  31. package/scripts/build-runtime-manifest.mjs +217 -0
  32. package/scripts/mneme-runtime.mjs +277 -0
  33. package/src/api.js +79 -6
  34. package/src/commands.js +6 -2
  35. package/src/config.js +80 -14
  36. package/src/dream/decisions.js +147 -14
  37. package/src/dream/sleep.js +84 -34
  38. package/src/dream.js +250 -60
  39. package/src/embedding.js +22 -2
  40. package/src/index.js +68 -37
  41. package/src/inject.js +16 -9
  42. package/src/lang.js +156 -0
  43. package/src/local-embedder.js +23 -9
  44. package/src/mirror.js +17 -14
  45. package/src/reranker.js +23 -8
  46. package/src/runtime/adopt.js +176 -0
  47. package/src/runtime/closure.js +172 -0
  48. package/src/runtime/download.js +247 -0
  49. package/src/runtime/layout.js +235 -0
  50. package/src/runtime/loader.js +249 -0
  51. package/src/runtime/provision.js +237 -0
  52. package/src/runtime/tarball.js +130 -0
  53. package/src/runtime/verify.js +191 -0
  54. package/src/service.js +102 -8
  55. package/src/settings.js +36 -2
  56. package/src/store.js +26 -8
  57. package/src/summarize.js +108 -35
  58. package/src/tools.js +119 -0
  59. package/test/api.test.js +89 -9
  60. package/test/client.test.js +94 -10
  61. package/test/config.test.js +17 -1
  62. package/test/dream-candidate-hybrid.test.js +281 -0
  63. package/test/dream.test.js +172 -0
  64. package/test/embedding-ready.test.js +196 -0
  65. package/test/helpers/runtime-source.js +60 -0
  66. package/test/helpers/tar-builder.js +80 -0
  67. package/test/inject.test.js +37 -1
  68. package/test/lang.test.js +87 -0
  69. package/test/quality-filter.test.js +68 -0
  70. package/test/reasoning-effort.test.js +106 -15
  71. package/test/reindex-backfill.test.js +62 -0
  72. package/test/runtime-adopt.test.js +206 -0
  73. package/test/runtime-cli.test.js +89 -0
  74. package/test/runtime-closure.test.js +193 -0
  75. package/test/runtime-download.test.js +302 -0
  76. package/test/runtime-layout.test.js +163 -0
  77. package/test/runtime-loader.test.js +237 -0
  78. package/test/runtime-manifest.test.js +158 -0
  79. package/test/runtime-provision.test.js +101 -0
  80. package/test/runtime-tarball.test.js +82 -0
  81. package/test/runtime-verify.test.js +214 -0
  82. package/test/sleep-action-set.test.js +422 -0
  83. package/test/store.test.js +14 -0
  84. package/test/summarize.test.js +224 -0
  85. package/test/tools.test.js +30 -5
  86. package/test/webServer-optional.test.js +34 -1
package/src/config.js CHANGED
@@ -2,6 +2,10 @@ import z from "@deepseek-ai/schemastery";
2
2
  import { TYPE_DECAY_DEFAULTS } from "./heat.js";
3
3
 
4
4
  export const Config = z.object({
5
+ // 记忆语言:生成记忆、注入标题与后台 LLM 提示词所用语言;'zh'(默认,
6
+ // 行为不变)/ 'en'。启动时读入,切换后重启生效。
7
+ language: z.union([z.const("zh"), z.const("en")]).default("zh")
8
+ .description("记忆语言:生成的记忆条目、注入标题与后台 LLM 提示词所用语言(zh=默认中文,en=英文)。"),
5
9
  memoryDir: z.string().default("~/.dsh/memory"),
6
10
  autoInject: z.boolean().default(true),
7
11
  autoSummarize: z.boolean().default(true),
@@ -14,6 +18,17 @@ export const Config = z.object({
14
18
  // 蒸馏把完整对话上下文交给 LLM 提炼,不硬裁到 8000 字就截断语义;默认
15
19
  // 24000 字符(约覆盖一整轮中等对话),需要更完整可调大。
16
20
  distillMaxChars: z.natural().min(1000).max(200000).default(24000),
21
+ // autoSummarize 节流、产出上限与写入端同会话去重(Issue #127,默认全 0/off =
22
+ // 零行为变化)。此前每个 turn/end 必跑、产出条数由模型输出决定、写入端只按
23
+ // 「同类型 + 标题 trim 全等」去重——本机实测单日 84 次、单会话一天 198 条、
24
+ // 同一事实 6 小时铸出 28+ 条。阀门交给用户按需拧,升级本身不改行为。
25
+ summarizeMinIntervalMinutes: z.natural().min(0).max(10080).default(0),
26
+ summarizeMaxEntriesPerRun: z.natural().min(0).max(50).default(0),
27
+ // 落库前去重档位:off(默认,等同现状)/ title(零成本,仅拦完全同名)/
28
+ // vector(复用已有 embedding 列做同会话语义近邻,无 LLM 调用)。
29
+ summarizeDedupeMode: z.union([z.const("off"), z.const("title"), z.const("vector")]).default("off"),
30
+ summarizeDedupeMinSim: z.number().min(0.5).max(0.99).default(0.92),
31
+ summarizeDedupeWindowHours: z.natural().min(0).max(168).default(24),
17
32
  // 智能调速器(429 保护,默认开):蒸馏 LLM 调用全局串行排队,相邻请求
18
33
  // 间隔 distillRateLimitIntervalMs(默认 1s 一次);命中 429 限流时按
19
34
  // distillRateLimitBaseDelayMs 指数退避(1s→2s→4s…)自动重试
@@ -64,25 +79,47 @@ export const Config = z.object({
64
79
  // 选用时建议配 dreamReasoningEffort 并实测;不行就换非思考模型。
65
80
  dreamProvider: z.string().description("记忆巩固专用模型的服务商(settings「巩固模型」)。巩固反复失败时,优先改用官方非思考模型的服务商(如 deepseek / glm)。"),
66
81
  dreamModel: z.string().description("记忆巩固专用模型。建议选非思考模型(如 deepseek-chat、glm-5-2 类):思考模型可能烧光 token 预算返回空体,导致巩固失败(UNSUPPORTED_REASONING_EFFORT)。"),
67
- dreamMaxTokens: z.natural().min(256).max(131072).default(32768),
68
- // Pass-through reasoning effort for dream's LLM calls. 'none' (default)
69
- // omits the field so the provider's own default applies; low/medium/high
70
- // are forwarded verbatim. Useful to cap reasoning spend on thinking-type
71
- // models that would otherwise drain the whole token budget and return an
72
- // empty body ("no json array in llm output").
82
+ // Issue #135 建议 3:#9 只抬了 max(8192→131072),default 停在 32768。思考型
83
+ // 模型的推理与正文共享该预算,effort 未压低时 32768 恰好被推理烧光 → 空体
84
+ // ("no json array in llm output",报告实测 146 轮因此失败)。默认抬到上限,
85
+ // 让默认配置也留足正文预算;流式计费按实际用量,不按上限。
86
+ dreamMaxTokens: z.natural().min(256).max(131072).default(131072),
87
+ // Pass-through reasoning effort for dream's LLM calls (Issue #135 建议 4/5).
88
+ // Unset (no value) resolves to the LOWEST effort the model declares before
89
+ // streaming: background consolidation must produce JSON, and omitting the
90
+ // field lets the harness substitute the model's defaultEffort (thinking-type
91
+ // models often default to high/max) — the reasoning then drains the whole
92
+ // token budget and the run dies with an empty body. Explicit 'none' still
93
+ // omits the field (provider default applies); off/low/medium/high are
94
+ // forwarded verbatim, unsupported values are remapped (v0.7.26+).
73
95
  // Caveat: on some thinking models (e.g. v4-flash-ga) the harness declares NO
74
96
  // supported effort, so even the fallback retry (field stripped) is rejected
75
97
  // again via its defaultEffort — prefer a non-reasoning dreamProvider/dreamModel.
76
98
  dreamReasoningEffort: z.union([
99
+ z.const("off"),
77
100
  z.const("low"),
78
101
  z.const("medium"),
79
102
  z.const("high"),
80
103
  z.const("none")
81
- ]).default("none").description("巩固模型的推理档位:'none'(默认)用服务商自带默认;low/medium/high 原样传递。模型不支持的值会自动换用其支持的档位(v0.7.26+)。"),
104
+ ]).description("巩固模型的推理档位:未配置 = 自动取模型支持的最低档(避免思考模型用自带默认档烧光预算返回空体,#135);显式 'none' = 不发送字段、用服务商自带默认;off/low/medium/high 原样传递。模型不支持的值会自动换用其支持的档位(v0.7.26+)。"),
82
105
  // 滑动窗口上限(v0.4.4):autoDream 每次只对最近 dreamMaxSnapshotSize 条
83
106
  // 记忆做 consolidation。大记忆量下全量快照会把 LLM 输入撑爆(636 记忆 →
84
107
  // 677 "missing" errors、applied=0),窗口外的旧记忆不进 snapshot。
85
108
  dreamMaxSnapshotSize: z.natural().min(1).max(1000).default(200),
109
+ // Issue #125:候选集构造方式。"window"(默认,等同现状)只取最近
110
+ // dreamMaxSnapshotSize 条;"hybrid" 在此基础上并入**向量翻出的高相似组**——
111
+ // 本机实测 45 对「双方活跃且 sim≥0.85」里 0 对能同时进窗口(窗口覆盖率 11.7%),
112
+ // 纯时间窗口让"该合并的一对"几乎永远碰不到面。候选总量仍由下面的上限封顶、
113
+ // 与库总量解耦,这是相对"调大窗口"的核心收益:输入成本不随库增长。
114
+ dreamCandidateMode: z.union([
115
+ z.const("window"),
116
+ z.const("hybrid")
117
+ ]).default("window"),
118
+ // hybrid 的候选总量上限;0 = 复用 dreamMaxSnapshotSize(不迁移,需要时显式覆盖)。
119
+ dreamCandidateMax: z.natural().min(0).max(5000).default(0),
120
+ // hybrid 判"高相似"的阈值;0.85 与 sleep normal 档对齐——两个模块对"高相似"
121
+ // 保持同一个定义。
122
+ dreamCandidateMinSim: z.number().min(0.5).max(0.99).default(0.85),
86
123
  // 隐式 keep(v0.4.4):LLM 未提及的 snapshot 记忆自动补 {action:"keep"},
87
124
  // 避免"未覆盖即全拒"白白浪费整轮 run。设为 false 时保留旧的严格校验
88
125
  // (未覆盖即拒绝整单)。
@@ -134,6 +171,20 @@ export const Config = z.object({
134
171
  embedModelCacheDir: z.string().default(""),
135
172
  embedModelMirror: z.string().default("https://hf-mirror.com"),
136
173
 
174
+ // 自管运行时目录(issue #131)。空 = ~/.dsh/mneme/runtime:里面放收编或下载来的
175
+ // transformers + onnxruntime 闭包,插件经 src/runtime/loader.js 的三层解析加载它。
176
+ // 目的就是让这条重依赖不必留在宿主 profile 的依赖图里——profile 是所有插件共用的
177
+ // 依赖图,留在里面会让「装任何插件都要替它重走一遍这条链」。
178
+ runtimeDir: z.string().default(""),
179
+
180
+ // 运行时取件的两个来源(issue #131 / PR-C)。都是「克制」的默认:留空 = 不额外改变行为。
181
+ // - runtimeTarballDir:本地 .tgz 目录。某个包网络下不到时(例如 onnxruntime-node),
182
+ // 把 `npm pack <pkg>` 出来的 tgz 丢进这个目录即可离线取件;有它就优先于联网。
183
+ // - runtimeMirror:registry 镜像前缀,例如 https://npmmirror.com/mirrors/npm/ 。
184
+ // 留空则用 runtime-manifest.json 里写死的官方地址(那是随包发布的固定清单)。
185
+ runtimeTarballDir: z.string().default(""),
186
+ runtimeMirror: z.string().default(""),
187
+
137
188
  // Vector search tuning.
138
189
  vectorSearchTopK: z.natural().min(1).max(100).default(20),
139
190
  vectorSearchThreshold: z.number().min(0).max(1).default(0.65),
@@ -228,9 +279,9 @@ export const Config = z.object({
228
279
  // default provider/model.
229
280
  entityExtractionModel: z.string().default(""),
230
281
  // Reasoning effort for entity extraction (issue #109), mirrors
231
- // dreamReasoningEffort: 'none' (default) omits the field / provider default;
232
- // low/medium/high passed through. A provider that rejects the effort retries
233
- // once without it, so opting in is safe to experiment with.
282
+ // dreamReasoningEffort pass-through: 'none' omits the field / provider
283
+ // default; off/low/medium/high passed through. A provider that rejects the
284
+ // effort retries once without it, so opting in is safe to experiment with.
234
285
  entityExtractionReasoning: z.union([
235
286
  z.const("low"),
236
287
  z.const("medium"),
@@ -265,6 +316,14 @@ export const Config = z.object({
265
316
  z.const("normal"),
266
317
  z.const("aggressive")
267
318
  ]).default("normal"),
319
+ // Issue #126:冲突阶段的动作集。"conflict"(默认 = 现状)只用 conflict/keep——
320
+ // 后台自动流程不静默扩张行为;"full" 开放六分支(merge / update / supersede /
321
+ // differentiate / conflict / keep),互补型与演进型重复因此有了正确出口,不再
322
+ // 被迫"输赢化"(实测抽样 10 对里 2 对属演进型/互补型,用 conflict 处理会丢信息)。
323
+ sleepActionSet: z.union([
324
+ z.const("conflict"),
325
+ z.const("full")
326
+ ]).default("conflict"),
268
327
  // Archival demotion tiering (days since last access):
269
328
  // >= sleepArchiveDays → shrink to summary, full body kept in _full_content
270
329
  // >= sleepCompressDays → archived outright (entity relations preserved)
@@ -281,14 +340,16 @@ export const Config = z.object({
281
340
  sleepProvider: z.string().default("").description("sleep 深维护专用模型服务商,留空用巩固模型或当前模型。"),
282
341
  sleepModel: z.string().default("").description("sleep 深维护专用模型,留空用巩固模型或当前模型;建议同巩固模型选非思考模型。"),
283
342
  // Pass-through reasoning effort for sleep's LLM passes, same semantics as
284
- // dreamReasoningEffort: 'none' (default) omits the field; low/medium/high
285
- // are forwarded verbatim.
343
+ // dreamReasoningEffort (Issue #135 建议 4/5): unset resolves to the lowest
344
+ // effort the model declares; explicit 'none' omits the field; off/low/
345
+ // medium/high are forwarded verbatim.
286
346
  sleepReasoningEffort: z.union([
347
+ z.const("off"),
287
348
  z.const("low"),
288
349
  z.const("medium"),
289
350
  z.const("high"),
290
351
  z.const("none")
291
- ]).default("none").description("同 dreamReasoningEffort:sleep 各阶段 LLM 的推理档位,'none' 用服务商默认。"),
352
+ ]).description("同 dreamReasoningEffort:sleep 各阶段 LLM 的推理档位,未配置 = 自动取模型支持的最低档;显式 'none' = 不发送字段、用服务商自带默认。"),
292
353
 
293
354
  // --- epistemic trust: memory source credibility (v0.4.5) -----------------
294
355
  // Distinguish memories by source: observation (measured / witnessed),
@@ -311,7 +372,12 @@ export const Config = z.object({
311
372
  enabled: z.boolean().default(true),
312
373
  archiveThreshold: z.natural().min(1).max(100).default(30),
313
374
  degradeThreshold: z.natural().min(1).max(100).default(60),
314
- minContentLength: z.natural().min(1).max(1000).default(10)
375
+ minContentLength: z.natural().min(1).max(1000).default(10),
376
+ // Issue #135 附属发现 1:importance ≥ 该值的记忆不参与静默自动归档——评分
377
+ // 与信号标签照常写入(可观测)、注入排序照常降权,但 setArchived 跳过。
378
+ // 报告实测 150 条低分归档里 128 条 importance ≥ 4(51 条 = 5)。设 1 = 全部
379
+ // 豁免(等效关闭自动归档),设 5 = 仅最重要的豁免。
380
+ exemptImportance: z.natural().min(1).max(5).default(4)
315
381
  }).default({}),
316
382
 
317
383
  // --- LLM audit trail (Bug8) ------------------------------------------------
@@ -1,4 +1,10 @@
1
- const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update", "create"]);
1
+ import { STR, langOf } from "../lang.js";
2
+
3
+ // Issue #126:sleepActionSet="full" 时新增两个动作——supersede(演进:新版取代
4
+ // 旧版)与 differentiate(互补:两条都留、各追加差异注记)。两者的校验与执行都
5
+ // 复用既有 merge/conflict 的机制(winner/loser、casGuard、transaction、receipt)。
6
+ // 调用方(sleep)按档位派生「本档允许的动作集合」时需要读它,故导出。
7
+ export const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update", "create", "supersede", "differentiate"]);
2
8
 
3
9
  // Epistemic trust (v0.4.5): when config.trustEpistemicWeighting is on, merge
4
10
  // keepSource and conflict winners prefer the higher-trust memory. Higher value
@@ -11,7 +17,8 @@ const EPISTEMIC_PRIORITY = { observation: 3, inferred: 2, subjective: 1 };
11
17
  * entries are spliced out of this array in place (the caller reuses the same
12
18
  * reference downstream for apply/audit); implicit keeps are appended here too.
13
19
  * @param snapshot - Map<id, memory> of eligible (non-archived, non-summary) entries.
14
- * @returns {{ok: boolean, errors: string[], skipped?: Array<{index, action, ids, error}>}}
20
+ * @returns {{ok: boolean, errors: string[], skipped?: Array<{index, action, ids, error}>,
21
+ * resolvedShortIds?: number}}
15
22
  */
16
23
  export function validateDecisions(decisions, snapshot, options = {}) {
17
24
  const errors = [];
@@ -25,15 +32,52 @@ export function validateDecisions(decisions, snapshot, options = {}) {
25
32
  const skipInvalid = options.skipInvalid === true;
26
33
  const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
27
34
  const minAgeHours = options.minAgeHours ?? 24;
35
+ // Issue #126 review(Copilot):档位白名单。sleepActionSet 默认档只换 prompt 是
36
+ // 不够的——ACTIONS 是两个模块共用的,模型自发出 supersede / differentiate 时校验器
37
+ // 照样放行,"opt-in 零行为变化"就成了空话。省略 / null = 不限制(沿用全局 ACTIONS)。
38
+ const allowedActions = Array.isArray(options.allowedActions) ? new Set(options.allowedActions) : null;
28
39
  if (!Array.isArray(decisions)) {
29
- return { ok: false, errors: ["decision list must be an array"] };
40
+ return { ok: false, errors: ["decision list must be an array"], resolvedShortIds: 0 };
30
41
  }
31
42
  // 空决策 = 模型完整评估后确认无需操作(CONSOLIDATION_PROMPT 明确允许"无问题的
32
43
  // 条目无需输出")。合法 JSON [] 不是空体(那是无输出/截断),也不是"残缺输出"
33
44
  // ——显式短路直接 ok,避免隐式 keep 的覆盖率检查把 0% 误判为模型坏了。与
34
45
  // sleep 的空模式(skipped no-op)语义对齐:下游 applied=0、audit 记 ok。
35
46
  if (decisions.length === 0) {
36
- return { ok: true, errors: [], skipped: [] };
47
+ return { ok: true, errors: [], skipped: [], resolvedShortIds: 0 };
48
+ }
49
+ // Issue #135:模型常把 36 位 UUID 回填成前缀(实测 78/78 个 8 位前缀都能唯一
50
+ // 对应窗口内真实记忆,且 few-shot 示例的 "m1"/"m2" 占位符在诱导这种缩写),而
51
+ // 下方校验用 snapshot.get(id) 整串精确匹配 → 全部 unknown id → claimed=0 →
52
+ // 覆盖率闸整单拒绝,一整轮语义成果被丢。校验前先把「唯一前缀」解析回完整 id
53
+ // (git 短哈希式):仅在 snapshot 内解析,有歧义或匹配不到就原样保留,仍由下
54
+ // 方 unknown id 校验如实报错。就地改写 ids / keepSource / winner / loser,下
55
+ // 游 applyDecisions 与审计拿到的同样是完整 id。
56
+ let resolvedShortIds = 0;
57
+ const resolveShortId = (value) => {
58
+ if (typeof value !== "string" || snapshot.has(value)) return value;
59
+ // 太短不猜(歧义概率高);长度 ≥36 说明不是前缀缩写,交给校验如实报错。
60
+ if (value.length < 8 || value.length >= 36) return value;
61
+ let hit = null;
62
+ let matches = 0;
63
+ for (const id of snapshot.keys()) {
64
+ if (id.startsWith(value)) {
65
+ hit = id;
66
+ if (++matches > 1) return value; // 有歧义,不解析
67
+ }
68
+ }
69
+ if (matches === 1) {
70
+ resolvedShortIds++;
71
+ return hit;
72
+ }
73
+ return value;
74
+ };
75
+ for (const d of decisions) {
76
+ if (!d || typeof d !== "object") continue;
77
+ if (Array.isArray(d.ids)) d.ids = d.ids.map(resolveShortId);
78
+ if (typeof d.keepSource === "string") d.keepSource = resolveShortId(d.keepSource);
79
+ if (typeof d.winner === "string") d.winner = resolveShortId(d.winner);
80
+ if (typeof d.loser === "string") d.loser = resolveShortId(d.loser);
37
81
  }
38
82
  const claimed = new Set();
39
83
  for (const [index, d] of decisions.entries()) {
@@ -41,12 +85,26 @@ export function validateDecisions(decisions, snapshot, options = {}) {
41
85
  // 单条校验错误先进 local:skipInvalid 模式下整条跳过,严格模式下才并入
42
86
  // 全局 errors(沿用 v0.6.9 的双轨结构)。
43
87
  const local = [];
44
- const ids = d && d.action === "conflict" ? [d.winner, d.loser] : (d?.ids ?? []);
88
+ // Issue #126:supersede 与 conflict 一样用 winner/loser 定位双方(沿用同一套
89
+ // 字段,校验与 receipt 逻辑因此可以直接复用)。
90
+ const ids = d && (d.action === "conflict" || d.action === "supersede") ? [d.winner, d.loser] : (d?.ids ?? []);
45
91
  if (!d || typeof d !== "object" || !ACTIONS.has(d.action)) {
46
92
  local.push(`${at}: invalid action ${JSON.stringify(d?.action)}`);
47
- } else if (d.action === "conflict") {
93
+ } else if (allowedActions && !allowedActions.has(d.action)) {
94
+ // 动作本身合法,但不在当前档位允许的集合里(例如默认档收到 supersede)。
95
+ local.push(`${at}: action ${JSON.stringify(d.action)} not allowed in this mode`);
96
+ } else if (d.action === "conflict" || d.action === "supersede") {
48
97
  if (!d.winner || !d.loser || d.winner === d.loser) {
49
- local.push(`${at}: conflict needs distinct winner and loser`);
98
+ local.push(`${at}: ${d.action} needs distinct winner and loser`);
99
+ }
100
+ } else if (d.action === "differentiate") {
101
+ // Issue #126:互补型——两条都留下、各追加差异注记。ids 必须 ≥2(随后走下方
102
+ // 通用 id 校验:unknown / archived / summary / 重复 claim),distinctions 必须
103
+ // 非空,否则会写出空注记(写了等于没写,还会污染正文)。
104
+ if (!Array.isArray(d.ids) || d.ids.length < 2) {
105
+ local.push(`${at}: differentiate needs at least two ids`);
106
+ } else if (!Array.isArray(d.distinctions) || !d.distinctions.some((s) => typeof s === "string" && s.trim())) {
107
+ local.push(`${at}: differentiate needs a non-empty distinctions array`);
50
108
  }
51
109
  } else if (d.action === "create") {
52
110
  // Mint a fresh memory (sleep pattern discovery). Claims no existing id,
@@ -170,14 +228,14 @@ export function validateDecisions(decisions, snapshot, options = {}) {
170
228
  // snapshot(claimed.size / snapshot.size < dreamMinExplicitCoverage)时整单拒绝,
171
229
  // 而不是用 keep 把绝大部分 snapshot 全部"通过"。
172
230
  if (errors.length > 0) {
173
- return { ok: false, errors, skipped };
231
+ return { ok: false, errors, skipped, resolvedShortIds };
174
232
  }
175
233
  const minCoverage = options.dreamMinExplicitCoverage ?? 0.5;
176
234
  if (options.dreamImplicitKeep !== false) {
177
235
  const coverage = snapshot.size > 0 ? claimed.size / snapshot.size : 1;
178
236
  if (coverage < minCoverage) {
179
237
  errors.push(`explicit decision coverage ${Math.round(coverage * 100)}% < minimum ${Math.round(minCoverage * 100)}%`);
180
- return { ok: false, errors, skipped };
238
+ return { ok: false, errors, skipped, resolvedShortIds };
181
239
  }
182
240
  for (const id of snapshot.keys()) {
183
241
  if (!claimed.has(id)) survivors.push({ action: "keep", ids: [id] });
@@ -186,7 +244,7 @@ export function validateDecisions(decisions, snapshot, options = {}) {
186
244
  for (const id of snapshot.keys()) {
187
245
  if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
188
246
  }
189
- if (errors.length > 0) return { ok: false, errors, skipped };
247
+ if (errors.length > 0) return { ok: false, errors, skipped, resolvedShortIds };
190
248
  }
191
249
  // 调用方下游(apply/audit)复用同一 decisions 引用:就地同步为 survivors——
192
250
  // 在 skipInvalid 模式下去掉被跳过的非法决策;在隐式 keep 下追加补齐的 keep。
@@ -194,7 +252,7 @@ export function validateDecisions(decisions, snapshot, options = {}) {
194
252
  // 当"被跳过的非法决策数 == 隐式补齐的 keep 数"时长度回到相等但内容已变,
195
253
  // 被跳过的决策会残留进 apply/audit。一律无条件 splice 最安全。
196
254
  decisions.splice(0, decisions.length, ...survivors);
197
- return { ok: true, errors, skipped };
255
+ return { ok: true, errors, skipped, resolvedShortIds };
198
256
  }
199
257
 
200
258
  /** Marker thrown when a decision target changed since the run snapshot. */
@@ -208,7 +266,7 @@ export class CasConflictError extends Error {
208
266
  }
209
267
 
210
268
  function decisionIds(d) {
211
- return d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
269
+ return d.action === "conflict" || d.action === "supersede" ? [d.winner, d.loser] : (d.ids ?? []);
212
270
  }
213
271
 
214
272
  /**
@@ -297,6 +355,8 @@ function applyOne(d, service, snapshot, config = {}) {
297
355
  case "archive": return applyArchive(d, service, snapshot);
298
356
  case "merge": return applyMerge(d, service, snapshot, config);
299
357
  case "conflict": return applyConflict(d, service, snapshot, config);
358
+ case "supersede": return applySupersede(d, service, snapshot, config);
359
+ case "differentiate": return applyDifferentiate(d, service, snapshot, config);
300
360
  case "create": return applyCreate(d, service, config);
301
361
  default: return applyUpdate(d, service, snapshot, config);
302
362
  }
@@ -330,6 +390,7 @@ function pickBestKeeper(ids, preferred, service) {
330
390
  * memories. saveWithDedupe dedupes identical mints (idempotent replay-safe).
331
391
  */
332
392
  function applyCreate(d, service, config = {}) {
393
+ const language = langOf(config);
333
394
  const title = String(d.title ?? "").trim();
334
395
  const content = String(d.content ?? "").trim();
335
396
  const importance = Number.isInteger(d.importance) ? d.importance : 3;
@@ -338,7 +399,7 @@ function applyCreate(d, service, config = {}) {
338
399
  ? d.evidence.filter((id) => typeof id === "string")
339
400
  : [];
340
401
  const body = evidence.length > 0
341
- ? `${content}\n\n[证据: ${evidence.join(", ")}]`
402
+ ? STR.evidenceSuffix[language](content, evidence)
342
403
  : content;
343
404
  const created = service.saveWithDedupe({ type, title, content: body, importance });
344
405
  const memory = created?.memory;
@@ -408,6 +469,7 @@ function applyMerge(d, service, snapshot, config = {}) {
408
469
  }
409
470
 
410
471
  function applyConflict(d, service, snapshot, config = {}) {
472
+ const language = langOf(config);
411
473
  // Epistemic trust (v0.4.5): when enabled, the observation side of a conflict
412
474
  // is preferred as winner over a subjective/inferred one.
413
475
  if (config.trustEpistemicWeighting === true) {
@@ -428,13 +490,84 @@ function applyConflict(d, service, snapshot, config = {}) {
428
490
  const loserNow = service.getById(d.loser);
429
491
  if (!winnerNow || !loserNow || loserNow.archived) return;
430
492
  service.update(d.winner, {
431
- content: `${winnerNow.content}\n\n(已否决旧信息:${[...loserNow.content].slice(0, 100).join("")})`
493
+ content: STR.supersededSuffix[language](winnerNow.content, [...loserNow.content].slice(0, 100).join(""))
432
494
  });
433
495
  service.setArchived(d.loser, true);
434
496
  });
435
497
  return { applied: 1, committed: { action: "conflict", winner: d.winner, loser: d.loser, count_before: 2, count_after: 1 } };
436
498
  }
437
499
 
500
+ /**
501
+ * supersede(Issue #126,sleepActionSet="full"):演进型重复——新版本取代旧版本。
502
+ * 与 conflict 的关键差别:**注记追加到 loser(被取代方)而不是 winner**。conflict
503
+ * 把"已否决旧信息"写进赢家正文,赢家因此被污染——这正是 #126 的原始抱怨;
504
+ * supersede 把可追溯性留在被归档的那一侧,赢家正文一字不动。
505
+ *
506
+ * 幂等:loser 已归档即视为该决策已落地(重放/并发不会重复追加注记或重复归档)。
507
+ */
508
+ function applySupersede(d, service, snapshot, config = {}) {
509
+ const language = langOf(config);
510
+ // Epistemic trust(与 conflict 同款):可信度更高的一侧优先作为取代方。
511
+ if (config.trustEpistemicWeighting === true) {
512
+ const pw = EPISTEMIC_PRIORITY[service.getById(d.winner)?.epistemic_status] ?? 0;
513
+ const pl = EPISTEMIC_PRIORITY[service.getById(d.loser)?.epistemic_status] ?? 0;
514
+ if (pl > pw) [d.winner, d.loser] = [d.loser, d.winner];
515
+ }
516
+ const winner = service.getById(d.winner);
517
+ const loser = service.getById(d.loser);
518
+ if (!winner || !loser) return "skipped";
519
+ if (loser.archived) return "skipped"; // 幂等重放:已经取代过了
520
+ service.transaction(() => {
521
+ casGuard(service, snapshot, [d.winner, d.loser]);
522
+ const winnerNow = service.getById(d.winner);
523
+ const loserNow = service.getById(d.loser);
524
+ if (!winnerNow || !loserNow || loserNow.archived) return;
525
+ service.update(d.loser, {
526
+ content: STR.supersededBySuffix[language](loserNow.content, winnerNow.title)
527
+ });
528
+ service.setArchived(d.loser, true);
529
+ });
530
+ return { applied: 1, committed: { action: "supersede", winner: d.winner, loser: d.loser, count_before: 2, count_after: 1 } };
531
+ }
532
+
533
+ /**
534
+ * differentiate(Issue #126,sleepActionSet="full"):互补型重复——两条各自成立、
535
+ * 覆盖不同侧面,不该判输赢。双方都保留(不归档),各追加同一段差异注记;注记进
536
+ * 正文 → 进注入与 embedding → 下一轮不会再被判成重复(这是本动作存在的意义)。
537
+ *
538
+ * 注记由插件按 distinctions 渲染,**不让模型整段重写 content**(重写有篡改/丢失
539
+ * 风险)。幂等:注记已在正文里就不重复追加,否则同一决策重放会不断堆叠。
540
+ */
541
+ function applyDifferentiate(d, service, snapshot, config = {}) {
542
+ const language = langOf(config);
543
+ const ids = Array.isArray(d.ids) ? d.ids : [];
544
+ const notes = (Array.isArray(d.distinctions) ? d.distinctions : [])
545
+ .filter((s) => typeof s === "string" && s.trim())
546
+ .map((s) => s.trim());
547
+ const marker = STR.differentiatedMarker[language](notes);
548
+ const alive = ids.filter((id) => {
549
+ const m = service.getById(id);
550
+ return m && !m.archived;
551
+ });
552
+ if (alive.length < 2) return "skipped"; // 缺一条即已处理/不可用(幂等)
553
+ if (alive.every((id) => String(service.getById(id)?.content ?? "").includes(marker))) {
554
+ return "skipped"; // 同一条决策重放:注记已存在
555
+ }
556
+ service.transaction(() => {
557
+ casGuard(service, snapshot, ids);
558
+ for (const id of ids) {
559
+ const m = service.getById(id);
560
+ if (!m || m.archived) continue;
561
+ if (String(m.content).includes(marker)) continue; // 单条已注记过
562
+ service.update(id, { content: `${m.content}${marker}` });
563
+ }
564
+ });
565
+ return {
566
+ applied: 1,
567
+ committed: { action: "differentiate", ids, count_before: ids.length, count_after: ids.length }
568
+ };
569
+ }
570
+
438
571
  function applyUpdate(d, service, snapshot, config = {}) {
439
572
  const id = d.ids[0];
440
573
  const mem = service.getById(id);