@modusensus/dsh-mneme 0.7.31 → 0.8.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.
Files changed (93) hide show
  1. package/README.md +65 -27
  2. package/lib/api.js +139 -6
  3. package/lib/client.js +369 -32
  4. package/lib/commands.js +36 -5
  5. package/lib/config.js +101 -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 +54 -13
  12. package/lib/lang.js +168 -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/scope.js +83 -0
  25. package/lib/service.js +374 -55
  26. package/lib/settings.js +43 -2
  27. package/lib/store.js +126 -13
  28. package/lib/summarize.js +108 -35
  29. package/lib/tools.js +205 -12
  30. package/package.json +7 -2
  31. package/runtime-manifest.json +596 -0
  32. package/scripts/build-runtime-manifest.mjs +217 -0
  33. package/scripts/mneme-runtime.mjs +277 -0
  34. package/src/api.js +139 -6
  35. package/src/commands.js +36 -5
  36. package/src/config.js +101 -14
  37. package/src/dream/decisions.js +147 -14
  38. package/src/dream/sleep.js +84 -34
  39. package/src/dream.js +250 -60
  40. package/src/embedding.js +22 -2
  41. package/src/index.js +68 -37
  42. package/src/inject.js +54 -13
  43. package/src/lang.js +168 -0
  44. package/src/local-embedder.js +23 -9
  45. package/src/mirror.js +17 -14
  46. package/src/reranker.js +23 -8
  47. package/src/runtime/adopt.js +176 -0
  48. package/src/runtime/closure.js +172 -0
  49. package/src/runtime/download.js +247 -0
  50. package/src/runtime/layout.js +235 -0
  51. package/src/runtime/loader.js +249 -0
  52. package/src/runtime/provision.js +237 -0
  53. package/src/runtime/tarball.js +130 -0
  54. package/src/runtime/verify.js +191 -0
  55. package/src/scope.js +83 -0
  56. package/src/service.js +374 -55
  57. package/src/settings.js +43 -2
  58. package/src/store.js +126 -13
  59. package/src/summarize.js +108 -35
  60. package/src/tools.js +205 -12
  61. package/test/api.test.js +175 -9
  62. package/test/client.test.js +152 -10
  63. package/test/commands.test.js +40 -3
  64. package/test/config.test.js +17 -1
  65. package/test/dream-candidate-hybrid.test.js +281 -0
  66. package/test/dream.test.js +172 -0
  67. package/test/embedding-ready.test.js +196 -0
  68. package/test/helpers/runtime-source.js +60 -0
  69. package/test/helpers/tar-builder.js +80 -0
  70. package/test/inject.test.js +130 -1
  71. package/test/lang.test.js +87 -0
  72. package/test/quality-filter.test.js +68 -0
  73. package/test/reasoning-effort.test.js +106 -15
  74. package/test/reindex-backfill.test.js +62 -0
  75. package/test/runtime-adopt.test.js +206 -0
  76. package/test/runtime-cli.test.js +89 -0
  77. package/test/runtime-closure.test.js +193 -0
  78. package/test/runtime-download.test.js +302 -0
  79. package/test/runtime-layout.test.js +163 -0
  80. package/test/runtime-loader.test.js +237 -0
  81. package/test/runtime-manifest.test.js +158 -0
  82. package/test/runtime-provision.test.js +101 -0
  83. package/test/runtime-tarball.test.js +82 -0
  84. package/test/runtime-verify.test.js +214 -0
  85. package/test/scope-retrieval.test.js +197 -0
  86. package/test/scope-storage.test.js +247 -0
  87. package/test/scope-strict.test.js +165 -0
  88. package/test/service.test.js +76 -0
  89. package/test/sleep-action-set.test.js +422 -0
  90. package/test/store.test.js +14 -0
  91. package/test/summarize.test.js +224 -0
  92. package/test/tools.test.js +30 -5
  93. package/test/webServer-optional.test.js +34 -1
@@ -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);
@@ -1,3 +1,4 @@
1
+ import { STR, langOf } from "../lang.js";
1
2
  // System-level sleep (v0.4.0): an idle-triggered, LLM-assisted deep pass over
2
3
  // the memory store. Four independent, fail-safe phases:
3
4
  // 1. conflict resolution — high-similarity same-type pairs are either parked
@@ -15,11 +16,16 @@
15
16
  // abortable via an AbortController signal (user activity) — phases check the
16
17
  // signal between batches so a running cycle yields promptly.
17
18
  import { randomUUID, createHash } from "node:crypto";
18
- import { validateDecisions, applyDecisions } from "./decisions.js";
19
- import { findPotentialConflicts } from "./clustering.js";
20
- import { buildReceipt, describeStreamFailure, resolveDreamEffort, withEffortFallback } from "../dream.js";
19
+ import { validateDecisions, applyDecisions, ACTIONS } from "./decisions.js";
20
+ import { findPotentialConflicts, cosineSimilarity } from "./clustering.js";
21
+ import { buildReceipt, describeStreamFailure, resolveDreamEffort, withEffortFallback, maintainIndexAfterDream } from "../dream.js";
21
22
  import { computeHeat } from "../heat.js";
22
23
 
24
+ // Issue #126 review(Copilot):默认档(sleepActionSet="conflict")允许的动作 = 全局
25
+ // ACTIONS 去掉 #126 新增的两个。从 ACTIONS 派生而非硬编码,将来再扩动作集时自动跟随。
26
+ // 只换 prompt 挡不住模型自发输出新动作,必须由校验器按档位收口。
27
+ const LEGACY_ACTIONS = [...ACTIONS].filter((a) => a !== "supersede" && a !== "differentiate");
28
+
23
29
  const SUMMARY_MAX = 120;
24
30
  // Conflict similarity threshold per strictness level (v0.4.0):
25
31
  // gentle only high-confidence pairs (0.92) — first-time users
@@ -27,26 +33,6 @@ const SUMMARY_MAX = 120;
27
33
  // aggressive low-confidence pairs too (0.75) — bloated stores
28
34
  const CONFLICT_THRESHOLDS = { gentle: 0.92, normal: 0.85, aggressive: 0.75 };
29
35
 
30
- const CONFLICT_PROMPT = `你是记忆库冲突仲裁助手。下面是检测到的高相似度记忆对,可能内容矛盾或重复。
31
- 对每一对输出一个 decision 对象:
32
- - 两条确实矛盾/重复 → { "action": "conflict", "winner": <保留的id>, "loser": <归档的id>, "reason": "理由" }
33
- - 两条只是主题相近、并无矛盾 → { "action": "keep", "ids": [<两个id>] }
34
- 规则:
35
- - winner 应为信息更完整、更新或更可信的一条
36
- - 只使用提供的 id,不要编造
37
- - 每对必须输出一个 decision
38
- - 只输出 JSON 数组,不要其他文字`;
39
-
40
- const PATTERN_PROMPT = `你是记忆库模式发现助手。下面是最近的记忆条目(id、类型、标题、内容)。
41
- 请发现跨条目的稳定模式:用户偏好的规律、反复出现的主题、可复用的工作流或项目规律。
42
- 对每个模式输出一个 create decision:
43
- { "action": "create", "type": "pattern", "title": "模式一句话标题", "content": "模式详细描述(2-4句)", "importance": 1-5, "evidence": ["支持该模式的记忆id"] }
44
- 规则:
45
- - 只输出有据可依的模式,宁缺毋滥
46
- - evidence 必须是列表中真实存在的 id
47
- - 最多输出 N 个模式
48
- - 只输出 JSON 数组,不要其他文字`;
49
-
50
36
  function parseJsonArray(text) {
51
37
  if (typeof text !== "string") return undefined;
52
38
  const start = text.indexOf("[");
@@ -109,6 +95,7 @@ function makeSummary(m) {
109
95
  * each pair → winner kept / loser archived. Returns a per-run summary.
110
96
  */
111
97
  async function phaseConflicts(ctx, service, config, logger, runId, semantic = null, signal = null) {
98
+ const language = langOf(config);
112
99
  const embedder = semantic?.embedder;
113
100
  const vectorIndex = semantic?.vectorIndex;
114
101
  if (!embedder || !vectorIndex || typeof embedder.embed !== "function") {
@@ -150,7 +137,24 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
150
137
  const usableMemories = usable.map((i) => memories[i]);
151
138
  const usableVectors = usable.map((i) => vectors[i]);
152
139
 
140
+ // Issue #126:动作集档位既要决定 prompt,也要决定**校验器允许的动作集合**(见下方
141
+ // allowedActions),并且在候选构造阶段就要知道——full 档需要补跨类型对。因此在这里
142
+ // 解析,而不是等到拼 prompt 时。
143
+ const actionSet = config.sleepActionSet ?? "conflict";
153
144
  const pairs = findPotentialConflicts(usableMemories, usableVectors, threshold);
145
+ if (actionSet === "full") {
146
+ // Issue #126 review(Copilot):findPotentialConflicts 只看同类型,而 full 档的
147
+ // prompt 明确宣传 differentiate 用于互补型——互补本来就常跨类型(同一事实的项目侧
148
+ // 与偏好侧)。不补这一遍,那条路径永远选不到。merge 的类型守卫不受影响:它拦在
149
+ // validateDecisions 里(allowCrossTypeMerge),不在候选生成。
150
+ for (let i = 0; i < usableMemories.length; i++) {
151
+ for (let j = i + 1; j < usableMemories.length; j++) {
152
+ if (usableMemories[i].type === usableMemories[j].type) continue;
153
+ const sim = cosineSimilarity(usableVectors[i], usableVectors[j]);
154
+ if (sim > threshold) pairs.push({ a: usableMemories[i], b: usableMemories[j], similarity: sim });
155
+ }
156
+ }
157
+ }
154
158
  if (pairs.length === 0) return { status: "skipped", reason: "no conflicts found" };
155
159
 
156
160
  // Dedupe: each memory participates in at most one pair, highest similarity
@@ -170,7 +174,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
170
174
  let frozen = 0;
171
175
  for (const p of selected) {
172
176
  try {
173
- service.saveConflictPending({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, reason: `相似度 ${p.similarity.toFixed(2)}` });
177
+ service.saveConflictPending({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, reason: STR.similarityReason[language](p.similarity.toFixed(2)) });
174
178
  frozen++;
175
179
  } catch (error) {
176
180
  logger?.warn?.(`dsh-mneme sleep: failed to freeze conflict ${p.a.id}/${p.b.id}: ${String(error)}`);
@@ -188,10 +192,13 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
188
192
  snapshot.set(p.b.id, p.b);
189
193
  }
190
194
  const listText = selected.map((p) =>
191
- `候选冲突:\nid=${p.a.id} | type=${p.a.type} | title=${p.a.title}\n${p.a.content}\n---\nid=${p.b.id} | type=${p.b.type} | title=${p.b.title}\n${p.b.content}\n(相似度 ${p.similarity.toFixed(2)})`
195
+ STR.candidateConflicts[language](p)
192
196
  ).join("\n\n");
193
197
  const sleepEffort = await resolveDreamEffort(ctx, route, config.sleepReasoningEffort, logger);
194
198
  let conflictStreamFailure = "";
199
+ // actionSet 已在候选构造前解析(跨类型候选需要它),这里只按档位选 prompt:
200
+ // 默认档保持只有 conflict/keep 的窄 prompt,"full" 才开放六分支。
201
+ const conflictPrompt = actionSet === "full" ? STR.prompts.conflictFull[language] : STR.prompts.conflict[language];
195
202
  const runConflict = (withEffort) => {
196
203
  conflictStreamFailure = "";
197
204
  return streamText(ctx, {
@@ -201,7 +208,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
201
208
  maxTokens: 2048,
202
209
  ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
203
210
  messages: [
204
- { role: "system", content: [{ type: "text", text: CONFLICT_PROMPT }] },
211
+ { role: "system", content: [{ type: "text", text: conflictPrompt }] },
205
212
  { role: "user", content: [{ type: "text", text: listText }] }
206
213
  ]
207
214
  }, (reason) => { conflictStreamFailure = describeStreamFailure(reason); });
@@ -218,7 +225,10 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
218
225
  // 预填作为防御性双保险——漏判读作"未裁决冲突"而非"冲突阶段整体失败"。
219
226
  const covered = new Set();
220
227
  for (const d of decisions) {
221
- if (d?.action === "conflict") {
228
+ // Issue #126 review(Copilot):supersede 同样用 winner/loser 定位(没有 ids),
229
+ // 此前只认 conflict → 它的两个目标都没进 covered → 被补成 implicit keep → 与
230
+ // supersede 的 claim 冲突:严格模式整单拒绝,宽容模式把合成的 keep 记成 skipped。
231
+ if (d?.action === "conflict" || d?.action === "supersede") {
222
232
  if (typeof d?.winner === "string") covered.add(d.winner);
223
233
  if (typeof d?.loser === "string") covered.add(d.loser);
224
234
  } else if (Array.isArray(d?.ids)) {
@@ -228,15 +238,43 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
228
238
  for (const id of snapshot.keys()) {
229
239
  if (!covered.has(id)) decisions.push({ action: "keep", ids: [id] });
230
240
  }
231
- const { ok, errors } = validateDecisions(decisions, snapshot, {});
232
- if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}` };
241
+ // Issue #126:sleep 是多对批量裁决,严格模式"一票否决"太脆(一对非法就丢掉整轮
242
+ // 成果)→ 与 dream 对齐透传 skipInvalid:非法的那一对只跳过自己,合法子集照常
243
+ // 应用,run 记 degraded。update 上限与保护期同样取 dream 的同一组配置,保证两个
244
+ // 模块对"什么算合法"的判据一致。
245
+ const { ok, errors, skipped } = validateDecisions(decisions, snapshot, {
246
+ maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
247
+ minAgeHours: config.reflectionUpdateMinAgeHours,
248
+ skipInvalid: config.dreamSkipInvalid !== false,
249
+ allowCrossTypeMerge: config.allowCrossTypeMerge === true,
250
+ // Issue #126 review(Copilot):默认档必须真的只放行旧动作集——只换 prompt 挡不住
251
+ // 模型自发输出 supersede / differentiate,那样"opt-in 零行为变化"就不成立。
252
+ allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS
253
+ });
254
+ if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}`, skipped };
233
255
  const { applied, failures, conflicts } = applyDecisions(decisions, service, logger, snapshot, config);
256
+ // Issue #126 review(Copilot):事务内 service.update 会跳过 scheduleEmbed(txDepth>0),
257
+ // 而 sleep 路径此前没有 dream 那样的 post-apply 索引维护——differentiate 的差异注记与
258
+ // supersede 的归档都反映不到缓存向量,"注记进 embedding 防再判重复"就是空承诺。
259
+ if (applied > 0 && semantic?.embedder && semantic?.vectorIndex) {
260
+ try {
261
+ await maintainIndexAfterDream(decisions, service, semantic);
262
+ } catch (error) {
263
+ logger?.warn?.(`dsh-mneme sleep: index maintenance failed: ${String(error)}`);
264
+ }
265
+ }
266
+ // Issue #126 review(Copilot):有决策被跳过时不能报 ok——与 dream 的 degraded 契约
267
+ // 对齐("合法子集已应用"是 degraded 而非 ok),否则消费方分不清整轮与残轮。
268
+ const degraded = skipped.length > 0;
234
269
  return {
235
- status: applied > 0 ? "ok" : failures.length ? "failed" : "noop",
270
+ status: applied > 0 ? (degraded ? "degraded" : "ok") : failures.length ? "failed" : "noop",
236
271
  pairs: selected.length,
237
272
  applied,
238
273
  failures,
239
- conflicts
274
+ conflicts,
275
+ // Issue #126:#104 同向——被跳过的逐对明细要进审计行,否则事后无法判断
276
+ // 是哪一对、因何被跳过(此前只进 logger.warn,splice 后就地消失)。
277
+ skipped
240
278
  };
241
279
  }
242
280
 
@@ -295,6 +333,7 @@ function phaseDemotion(service, config, logger, runId, signal = null) {
295
333
  * "every id claimed" invariant is trivially satisfied for pure-create lists.
296
334
  */
297
335
  async function phasePatterns(ctx, service, config, logger, runId, signal = null) {
336
+ const language = langOf(config);
298
337
  const route = resolveSleepRoute(ctx, config, logger);
299
338
  if (!route) return { status: "skipped", reason: "no llm route" };
300
339
  const limit = config.sleepPatternMinMemories ?? 100;
@@ -320,7 +359,7 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
320
359
  maxTokens: 2048,
321
360
  ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
322
361
  messages: [
323
- { role: "system", content: [{ type: "text", text: PATTERN_PROMPT.replace("N", String(maxPatterns)) }] },
362
+ { role: "system", content: [{ type: "text", text: STR.prompts.pattern[language].replace("N", String(maxPatterns)) }] },
324
363
  { role: "user", content: [{ type: "text", text: listText }] }
325
364
  ]
326
365
  }, (reason) => { patternStreamFailure = describeStreamFailure(reason); });
@@ -417,7 +456,11 @@ function deriveStatus(phases) {
417
456
  const list = Object.values(phases);
418
457
  if (list.length === 0) return "noop";
419
458
  const anyError = list.some((p) => p.status === "failed" || p.status === "error");
420
- const anyWork = list.some((p) => p.status === "ok");
459
+ // Issue #126 review(Copilot):phase 自身可以是 degraded(合法子集已应用但有条目被
460
+ // 跳过),它同样算"有产出"——否则整轮会被误判成 noop,把真实变更报成空轮。
461
+ const anyWork = list.some((p) => p.status === "ok" || p.status === "degraded");
462
+ const anyDegraded = list.some((p) => p.status === "degraded");
463
+ if (anyDegraded) return "degraded";
421
464
  if (anyWork && anyError) return "degraded";
422
465
  if (anyError) return "failed";
423
466
  if (anyWork) return "ok";
@@ -449,6 +492,12 @@ export async function runSleep(ctx, service, config, logger, semantic = null, si
449
492
  const status = deriveStatus(phases);
450
493
  const route = resolveSleepRoute(ctx, config, logger);
451
494
  const totalApplied = Object.values(phases).reduce((n, p) => n + (Number.isInteger(p?.applied) ? p.applied : 0), 0);
495
+ // Issue #126(与 #104 同向):把各 phase 的跳过明细汇总成一行审计数据,带 phase
496
+ // 名(同一次 run 里 conflicts 与 patterns 都可能产出 skipped)。全空时写 NULL,
497
+ // 不落空数组——审计行只记真实发生过的跳过。
498
+ const skippedDetail = Object.entries(phases).flatMap(([phase, p]) =>
499
+ Array.isArray(p?.skipped) ? p.skipped.map((s) => ({ phase, ...s })) : []
500
+ );
452
501
  const snapshotHash = createHash("sha256").update(JSON.stringify(phases)).digest("hex");
453
502
  const receipt = buildReceipt({
454
503
  runId,
@@ -473,7 +522,8 @@ export async function runSleep(ctx, service, config, logger, semantic = null, si
473
522
  summary_stored: false,
474
523
  receipt,
475
524
  policy_epoch: config.policyEpoch ?? 0,
476
- run_type: "sleep"
525
+ run_type: "sleep",
526
+ skipped: skippedDetail.length ? skippedDetail : undefined
477
527
  });
478
528
  } catch (error) {
479
529
  logger?.warn?.(`dsh-mneme sleep: failed to record audit run: ${String(error)}`);