@modusensus/dsh-mneme 0.11.0 → 0.13.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 (174) hide show
  1. package/README.md +54 -21
  2. package/docs/CONFIGURATION.md +45 -4
  3. package/docs/DIRECTORIES.md +114 -0
  4. package/docs/DISTILL.md +64 -0
  5. package/docs/DSH-GO.md +97 -0
  6. package/docs/ENTITIES.md +5 -3
  7. package/docs/FORGET.md +77 -0
  8. package/docs/KB.md +110 -0
  9. package/docs/SLEEP.md +28 -3
  10. package/docs/STORAGE.md +23 -0
  11. package/lib/api-standalone.js +4 -1
  12. package/lib/api.js +744 -26
  13. package/lib/attachment-text.js +122 -0
  14. package/lib/choro-command.js +9 -2
  15. package/lib/client.js +3752 -819
  16. package/lib/config.js +171 -2
  17. package/lib/continuity.js +7 -6
  18. package/lib/diary/auto.js +190 -0
  19. package/lib/diary/compose.js +440 -0
  20. package/lib/diary/dir.js +59 -0
  21. package/lib/diary/export.js +97 -0
  22. package/lib/diary/index.js +26 -0
  23. package/lib/diary/library.js +189 -0
  24. package/lib/diary/qq-inbox.js +142 -0
  25. package/lib/diary/rollover.js +61 -0
  26. package/lib/diary/session-provider.js +122 -0
  27. package/lib/document.js +9 -3
  28. package/lib/dream/clustering.js +6 -1
  29. package/lib/dream/decisions.js +56 -5
  30. package/lib/dream/sleep.js +616 -15
  31. package/lib/dream.js +45 -13
  32. package/lib/dsh-go/host.js +162 -0
  33. package/lib/dsh-go/panel.js +2091 -0
  34. package/lib/dsh-go/text.js +342 -0
  35. package/lib/dsh-go/views/common.js +68 -0
  36. package/lib/dsh-go/views/entity.js +48 -0
  37. package/lib/dsh-go/views/memory.js +198 -0
  38. package/lib/dsh-go/views/root.js +30 -0
  39. package/lib/dsh-go/views/settings.js +398 -0
  40. package/lib/dsh-go/views/status.js +51 -0
  41. package/lib/embedding.js +42 -8
  42. package/lib/entities/extractor.js +36 -7
  43. package/lib/hot-memory.js +8 -9
  44. package/lib/index.js +131 -5
  45. package/lib/inject.js +192 -19
  46. package/lib/kb/index.js +309 -0
  47. package/lib/kb/routes.js +220 -0
  48. package/lib/kb/tools.js +148 -0
  49. package/lib/lang.js +115 -4
  50. package/lib/peak-hours.js +57 -2
  51. package/lib/profile-facts.js +356 -0
  52. package/lib/semantic.js +347 -60
  53. package/lib/service.js +302 -70
  54. package/lib/settings.js +117 -14
  55. package/lib/store.js +830 -55
  56. package/lib/summarize.js +38 -11
  57. package/lib/tools.js +173 -25
  58. package/lib/user-settings.js +175 -23
  59. package/lib/vector-index.js +76 -6
  60. package/lib/wiki/cli-hint.js +90 -0
  61. package/lib/wiki/queries.js +264 -10
  62. package/lib/wiki/t1-jobs.js +232 -0
  63. package/lib/wiki/t1.js +7 -1
  64. package/package.json +25 -1
  65. package/scripts/sync-test-badge.mjs +1 -1
  66. package/scripts/test-count-sync.mjs +9 -5
  67. package/src/api-standalone.js +4 -1
  68. package/src/api.js +744 -26
  69. package/src/attachment-text.js +122 -0
  70. package/src/choro-command.js +9 -2
  71. package/src/config.js +171 -2
  72. package/src/continuity.js +7 -6
  73. package/src/diary/auto.js +190 -0
  74. package/src/diary/compose.js +440 -0
  75. package/src/diary/dir.js +59 -0
  76. package/src/diary/export.js +97 -0
  77. package/src/diary/index.js +26 -0
  78. package/src/diary/library.js +189 -0
  79. package/src/diary/qq-inbox.js +142 -0
  80. package/src/diary/rollover.js +61 -0
  81. package/src/diary/session-provider.js +122 -0
  82. package/src/document.js +9 -3
  83. package/src/dream/clustering.js +6 -1
  84. package/src/dream/decisions.js +56 -5
  85. package/src/dream/sleep.js +616 -15
  86. package/src/dream.js +45 -13
  87. package/src/dsh-go/host.js +162 -0
  88. package/src/dsh-go/panel.js +2091 -0
  89. package/src/dsh-go/text.js +342 -0
  90. package/src/dsh-go/views/common.js +68 -0
  91. package/src/dsh-go/views/entity.js +48 -0
  92. package/src/dsh-go/views/memory.js +198 -0
  93. package/src/dsh-go/views/root.js +30 -0
  94. package/src/dsh-go/views/settings.js +398 -0
  95. package/src/dsh-go/views/status.js +51 -0
  96. package/src/embedding.js +42 -8
  97. package/src/entities/extractor.js +36 -7
  98. package/src/hot-memory.js +8 -9
  99. package/src/index.js +131 -5
  100. package/src/inject.js +192 -19
  101. package/src/kb/index.js +309 -0
  102. package/src/kb/routes.js +220 -0
  103. package/src/kb/tools.js +148 -0
  104. package/src/lang.js +115 -4
  105. package/src/peak-hours.js +57 -2
  106. package/src/profile-facts.js +356 -0
  107. package/src/semantic.js +347 -60
  108. package/src/service.js +302 -70
  109. package/src/settings.js +117 -14
  110. package/src/store.js +830 -55
  111. package/src/summarize.js +38 -11
  112. package/src/tools.js +173 -25
  113. package/src/user-settings.js +175 -23
  114. package/src/vector-index.js +76 -6
  115. package/src/wiki/cli-hint.js +90 -0
  116. package/src/wiki/queries.js +264 -10
  117. package/src/wiki/t1-jobs.js +232 -0
  118. package/src/wiki/t1.js +7 -1
  119. package/test/api-routes-gaps.test.js +63 -0
  120. package/test/api.test.js +377 -4
  121. package/test/attachment-text.test.js +140 -0
  122. package/test/bm25-corpus-cache.test.js +145 -0
  123. package/test/capacity-warning.test.js +262 -0
  124. package/test/client.test.js +908 -138
  125. package/test/config.test.js +79 -0
  126. package/test/conflict-freeze.test.js +45 -1
  127. package/test/continuity.test.js +8 -1
  128. package/test/diary-library.test.js +187 -0
  129. package/test/diary.test.js +576 -0
  130. package/test/document.test.js +32 -0
  131. package/test/dream-maxtokens-fallback.test.js +3 -3
  132. package/test/dream.test.js +62 -1
  133. package/test/dsh-go-host.test.js +134 -0
  134. package/test/dsh-go-panel-actions.test.js +854 -0
  135. package/test/dsh-go-panel.test.js +251 -0
  136. package/test/embedding-cache-f32.test.js +276 -0
  137. package/test/embedding-ready.test.js +33 -3
  138. package/test/entities.test.js +73 -0
  139. package/test/forget-corrections.test.js +70 -0
  140. package/test/image-memory.test.js +171 -0
  141. package/test/inject-pin-explicit.test.js +300 -0
  142. package/test/inject-pin-vector-crowd.test.js +225 -0
  143. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  144. package/test/kb-api-wiring.test.js +128 -0
  145. package/test/kb-collections.test.js +272 -0
  146. package/test/kb-routes.test.js +379 -0
  147. package/test/mint-source.test.js +26 -0
  148. package/test/peak-hours-observability.test.js +70 -0
  149. package/test/persona-api.test.js +3 -3
  150. package/test/persona-by-session.test.js +568 -0
  151. package/test/persona-settings.test.js +38 -7
  152. package/test/profile-facts.test.js +492 -0
  153. package/test/reflection-update-gate.test.js +129 -0
  154. package/test/reindex-backfill.test.js +334 -0
  155. package/test/reindex-scheduler.test.js +369 -0
  156. package/test/scope-candidates.test.js +249 -0
  157. package/test/session-gate.test.js +1 -1
  158. package/test/sleep-action-set.test.js +14 -0
  159. package/test/sleep-heat.test.js +49 -0
  160. package/test/sleep.test.js +598 -0
  161. package/test/standalone-api.test.js +24 -0
  162. package/test/store.test.js +20 -0
  163. package/test/summarize.test.js +26 -0
  164. package/test/test-count-sync.test.js +14 -3
  165. package/test/tool-state-ignore.test.js +65 -0
  166. package/test/tools-dto-schema-matrix.test.js +23 -2
  167. package/test/tools.test.js +144 -7
  168. package/test/user-settings-segments.test.js +528 -0
  169. package/test/vector-index.test.js +120 -6
  170. package/test/wiki-api.test.js +476 -1
  171. package/test/wiki-cli-hint.test.js +118 -0
  172. package/test/wiki-queries.test.js +91 -1
  173. package/test/wiki-t1-jobs.test.js +125 -0
  174. package/test/wiki-tools.test.js +131 -76
@@ -13,6 +13,9 @@ import { STR, langOf } from "../lang.js";
13
13
  // type=pattern entries carrying evidence id references.
14
14
  // 4. relation completion — orphan entities (zero relations) get implied
15
15
  // relations completed from memory co-occurrence.
16
+ // 5. affective attribution (v0.13.0, #619) — opt-in. Occasion-bound emotions
17
+ // become affective_context relations; only emotions recurring across
18
+ // enough independent sessions are minted as type=pattern traits.
16
19
  // Each phase is wrapped so one failure never aborts the others, and a missing
17
20
  // LLM route / semantic embedder only skips the phases that need it. A run is
18
21
  // abortable via an AbortController signal (user activity) — phases check the
@@ -356,6 +359,11 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
356
359
  // · 只筛同 scope 对:跨 scope 对本来就要停车进人工队列,不能被台账吞掉;
357
360
  // · freeze 模式不筛:那一档只停车不送 LLM,台账与它无关(ledgerSkipped 记 0);
358
361
  // · ledgerSkipped 在这里一次数清,后面 candidates 直接取 samePairs,不再二次过滤。
362
+ // ⚠️ Issue #616:这个判据把「未标注」当成「同 scope」。scopeKeyOf 把 NULL/空串
363
+ // 归一成 null,于是两条都没标注时 null !== null 为 false,配对算同 scope、可以进
364
+ // 自动池。但「归属未知」和「归属相同」不是一回事:按 v0.8.1 #170,前者本该是不可
365
+ // 自动的范围。live 库活跃 8259 行里 2710 行(32.8%)两个 scope 全 NULL,受影响的
366
+ // 面不小。修法是把「任一未标注」显式判为不可自动,而不是继续让 null 自我相等。
359
367
  const isCrossScopePair = (p) =>
360
368
  scopeKeyOf(p.a.agent_scope) !== scopeKeyOf(p.b.agent_scope)
361
369
  || scopeKeyOf(p.a.workspace_scope) !== scopeKeyOf(p.b.workspace_scope);
@@ -389,33 +397,200 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
389
397
  // (专属 reason),同 scope 对维持既有路径(freeze 停车 / LLM 裁决)。
390
398
  const crossPairs = selected.filter(isCrossScopePair);
391
399
  const samePairs = selected.filter((p) => !isCrossScopePair(p));
400
+ // 四态:fresh(真的新插了一行)/ existing(这一对已在队列里)/ suppressed
401
+ // (已人工裁决且两侧未变,复核项 4)/ failed(写库抛错)。旧代码把
402
+ // 「返回非 null」一律当停车成功,existing 因此每轮虚报一遍 frozen(#603)。
392
403
  const parkPair = (p, reason) => {
393
404
  try {
394
- // 返回 undefined = 该对已被人工裁决且两侧内容未变(复核项 4)——不算停车。
395
- return service.saveConflictPending({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, reason }) != null;
405
+ const parked = service.saveConflictPending({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, reason, similarity: p.similarity });
406
+ if (parked == null) return "suppressed";
407
+ return parked.fresh === true ? "fresh" : "existing";
396
408
  } catch (error) {
397
409
  logger?.warn?.(`dsh-mneme sleep: failed to park conflict ${p.a.id}/${p.b.id}: ${String(error)}`);
398
- return false;
410
+ return "failed";
399
411
  }
400
412
  };
401
413
  const scopeReason = (p) => STR.scopeCandidateReason[language](p.similarity.toFixed(2));
402
414
  const simReason = (p) => STR.similarityReason[language](p.similarity.toFixed(2));
403
415
 
416
+ // 待审对的键集合(与 pairKey 同形)。只用于 freeze 分支的「先滤已入队」,
417
+ // 分页拉全量:队列规模不受 50 条默认页长限制。
418
+ const loadPendingPairKeys = () => {
419
+ const keys = new Set();
420
+ if (typeof service.listConflictPending !== "function") return keys;
421
+ const pageSize = 500;
422
+ for (let offset = 0; ; offset += pageSize) {
423
+ const page = service.listConflictPending({ limit: pageSize, offset, includeResolved: false }) ?? [];
424
+ for (const row of page) keys.add(`${row.memory_a}\u0000${row.memory_b}`);
425
+ if (page.length < pageSize) break;
426
+ }
427
+ return keys;
428
+ };
429
+
404
430
  // Freeze mode: park pairs for manual review, no LLM required. Cross-scope
405
431
  // pairs carry the dedicated scope-candidate reason so the queue reads them
406
432
  // as ownership decisions rather than plain duplicates.
433
+ //
434
+ // Issue #603 第 1 层:这条分支此前绕过全部三个上限、直接裸跑 selected。
435
+ // · 先滤已入队再截断——裸 slice 会饥饿:上限恒小于候选数时,前 N 个名额
436
+ // 每轮都被同一批已在队列里的对占住,后面的对永远轮不上;
437
+ // · 接预算:conflictFreezeMaxPending 决定队列还能装多少,
438
+ // sleepConflictMaxPairsPerRun 决定一轮最多处理多少,取两者较小值;
439
+ // · 只把 fresh 计入 frozen,已入队的对不再虚报。
440
+ //
441
+ // ⚠️ Issue #616:下面这段 freeze 分支同时也是「自动裁决档」的唯一宿主——
442
+ // conflictAutoResolveEnabled 的全部逻辑(autoEnabled / autoPool / autoResolved)
443
+ // 都在这对花括号里面。两个配置键在面板上看是平行的,实际是父子:把 freeze
444
+ // 关掉,自动档会跟着静默消失,没有任何提示。拆开之前别把它们当独立开关用。
407
445
  if (config.conflictFreezeEnabled === true) {
446
+ const maxPending = Number.isInteger(config.conflictFreezeMaxPending) ? config.conflictFreezeMaxPending : 100;
447
+ const pendingNow = typeof service.countConflictPending === "function" ? (service.countConflictPending() ?? 0) : 0;
448
+ const room = Math.max(0, maxPending - pendingNow);
449
+ const pendingKeys = loadPendingPairKeys();
450
+ const unparked = selected.filter((p) => !pendingKeys.has(pairKey(p)));
451
+ const withinRoom = room > 0 ? unparked.slice(0, room) : [];
452
+ const pendingSkipped = unparked.length - withinRoom.length;
453
+ const perRunCap = config.sleepConflictMaxPairsPerRun ?? 60;
454
+ const pool = perRunCap > 0 ? withinRoom.slice(0, perRunCap) : withinRoom;
455
+ // 只报「有位置但被每轮上限截住」的那部分:队列满时再追一轮也没用,
456
+ // 报成 backlog 会让调度器空转(catchup 上限 24 轮)。
457
+ const perRunSkipped = withinRoom.length - pool.length;
458
+ // Issue #603 第 4 层(自动裁决档,默认关):相似度极高的**同 scope** 对不再
459
+ // 进人工队列,直接保留信息量更大的那条、归档另一条。人只看模糊地带。
460
+ //
461
+ // 为什么阈值留在配置里而不是写死 0.95:这一档减的是峰值,
462
+ // 不是把队列清零——把阈值当常量会让人以为「开了就没事了」。
463
+ //
464
+ // 为什么跨 scope 永不自动:归属是用户的决定(v0.8.1 #170),归档败者可能
465
+ // 销毁某归属下的唯一副本。这条约束比减负重要。
466
+ //
467
+ // ⚠️ Issue #616:这条守卫有一个洞。isCrossScopePair 走 scopeKeyOf,NULL 归一
468
+ // 成 null,null !== null 判为 false —— 「未标注」因此被当成「同 scope」混进
469
+ // autoPool。live 库活跃 8259 行里 2710 行(32.8%)两个 scope 全 NULL。改判据
470
+ // 之前,别把这一档当成「归属安全」。
471
+ //
472
+ // 为什么用 supersede 而不是 conflict:conflict 会把「(已否决旧信息:…)」
473
+ // 追加到**赢家**正文,而赢家正是留下来的那条;近重复对再叠一段互相引用的
474
+ // 注记,等于用冗余污染幸存记忆。supersede 把可追溯性留在被归档的一侧,
475
+ // 赢家正文一字不动(#126 的原设计)。
476
+ const autoEnabled = config.conflictAutoResolveEnabled === true;
477
+ const autoMin = typeof config.conflictAutoResolveMinSimilarity === "number"
478
+ ? config.conflictAutoResolveMinSimilarity
479
+ : 0.95;
480
+ const autoPool = [];
481
+ const parkPool = [];
482
+ for (const p of pool) {
483
+ const eligible = autoEnabled
484
+ && !isCrossScopePair(p)
485
+ && typeof p.similarity === "number"
486
+ && p.similarity >= autoMin;
487
+ (eligible ? autoPool : parkPool).push(p);
488
+ }
489
+ let autoResolved = 0;
490
+ let autoFailed = 0;
491
+ // 这一档不走 LLM,没有批次时间戳可借(judgedAt 在下面那条 LLM 通路里才定义),
492
+ // 自己取一个:台账要能回答「什么时候判的」,不能留空。
493
+ const autoJudgedAt = new Date().toISOString();
494
+ // 保留**信息量更大**的那条,不是更新的那条。
495
+ //
496
+ // 「保留新的」看着合理,实测是错的:live 库 >=0.95 的 20 对里,11 对(55%)
497
+ // 新的那条比旧的短,平均短 132 字;降到 0.88 的 213 对里 122 对(57%)如此。
498
+ // 典型例子:「用户同时开 web profile 和 desktop profile 时出现开不了新对话/
499
+ // 会话异常,用户自己…」(371 字,旧)被压成「用户同时开 web profile 和
500
+ // desktop profile 的 dsh-mneme 时开不了新对话」(307 字,新)——蒸馏时压缩过
501
+ // 的一版反而更新。留新条等于把更完整的那条归档掉。
502
+ //
503
+ // 这与 mem0 的裁决规则一致:`configs/prompts.py:217` 明写「两句话说的是同一
504
+ // 件事时,留信息量最大的那条,不是留最新的」。cognee 反过来(「取更新的」),
505
+ // 那是反例——照抄会把「0:00 前必须躺下」削成残缺版。
506
+ //
507
+ // 长度相同才算「信息量相当」,此时退回更新时间,再退回 id:三级兜底保证同一
508
+ // 对每次判出同一个赢家(否则重放会来回翻,归档的可能是刚保住的那条)。
509
+ const infoFirst = (p) => {
510
+ const an = p.a.content?.length ?? 0;
511
+ const bn = p.b.content?.length ?? 0;
512
+ if (an !== bn) return an > bn ? [p.a, p.b] : [p.b, p.a];
513
+ const au = p.a.updated_at ?? "";
514
+ const bu = p.b.updated_at ?? "";
515
+ if (au !== bu) return au > bu ? [p.a, p.b] : [p.b, p.a];
516
+ return p.a.id > p.b.id ? [p.a, p.b] : [p.b, p.a];
517
+ };
518
+ for (const p of autoPool) {
519
+ const [winner, loser] = infoFirst(p);
520
+ const snapshot = new Map();
521
+ for (const m of [winner, loser]) {
522
+ snapshot.set(m.id, { updated_at: m.updated_at, content: m.content, title: m.title });
523
+ }
524
+ // trustEpistemicWeighting 显式关掉:它会在 applySupersede 里按 epistemic_status
525
+ // 交换赢家,那会让「留信息量更大的那条」这条契约随数据漂移。自动档的承诺
526
+ // 必须是确定的。
527
+ const { applied, conflicts } = applyDecisions(
528
+ [{ action: "supersede", winner: winner.id, loser: loser.id }],
529
+ service, logger, snapshot, { ...config, trustEpistemicWeighting: false }
530
+ );
531
+ if (applied > 0) {
532
+ autoResolved++;
533
+ // 记进机器侧台账:自动动作也要能事后回答「这一对什么时候、被谁判的」。
534
+ try {
535
+ service.saveConflictReviewed?.({
536
+ run_id: runId, memory_a: p.a.id, memory_b: p.b.id,
537
+ outcome: "supersede", judged_at: autoJudgedAt
538
+ });
539
+ } catch (error) {
540
+ logger?.warn?.(`dsh-mneme sleep: failed to record auto-resolved pair ${p.a.id}/${p.b.id}: ${String(error)}`);
541
+ }
542
+ continue;
543
+ }
544
+ // 自动档没落地 → 退回人工队列。绝不因为「本来想自动处理」就把这一对丢掉,
545
+ // 那才是真正的静默丢失。两种没落地的原因都要说清楚:
546
+ // · CAS 冲突(applyDecisions 记在 conflicts 里)——两侧在快照之后被改过,
547
+ // 自动判的赢家可能已经不是该保的那条,人看一眼最稳;
548
+ // · 其余异常(记在 failures 里)——写库失败之类,重试无意义就交给人。
549
+ autoFailed++;
550
+ logger?.warn?.(
551
+ `dsh-mneme sleep: auto-resolve skipped ${p.a.id}/${p.b.id}`
552
+ + (conflicts.length ? ` (concurrent edit)` : "")
553
+ + `, falling back to manual review`
554
+ );
555
+ parkPool.push(p);
556
+ }
408
557
  let frozen = 0;
409
- for (const p of selected) {
410
- if (parkPair(p, isCrossScopePair(p) ? scopeReason(p) : simReason(p))) frozen++;
558
+ let parkFailed = 0;
559
+ for (const p of parkPool) {
560
+ const outcome = parkPair(p, isCrossScopePair(p) ? scopeReason(p) : simReason(p));
561
+ if (outcome === "fresh") frozen++;
562
+ else if (outcome === "failed") parkFailed++;
563
+ }
564
+ if (pendingSkipped > 0) {
565
+ logger?.warn?.(`dsh-mneme sleep: conflict freeze queue full (${pendingNow}/${maxPending}), skipped ${pendingSkipped} conflict(s)`);
411
566
  }
412
- return { status: frozen > 0 ? "ok" : "noop", frozen, scopeCandidates: crossPairs.length, pairs: selected.length };
567
+ return {
568
+ status: frozen > 0 || autoResolved > 0 ? "ok" : "noop",
569
+ frozen,
570
+ // 自动裁决掉的不进队列,但确实是这一轮干的活——分开报,别混进 frozen
571
+ // (frozen 的语义是「给人工队列新增了几行」,混进去面板就会虚高)。
572
+ // Issue #616:这两个字段目前**没有消费者**。dream.js 的两处 return 只透出
573
+ // frozen,面板也不读它们;conflict_reviewed 台账同样没有 API 暴露,且 schema
574
+ // 不记赢家(memory_a/b 是排序过的)。也就是说自动裁决干了什么,事后只能翻
575
+ // loser 正文的「(已被取代:…)」后缀反推。补可见性之前,别指望从报告里看出
576
+ // 这一档跑了几对。
577
+ autoResolved,
578
+ autoFailed,
579
+ // 写库抛错的对既没入队也不算落地:clean=false,别让调度器当「追完了」。
580
+ clean: parkFailed === 0,
581
+ scopeCandidates: crossPairs.length,
582
+ pairs: selected.length,
583
+ batches: 0,
584
+ ledgerSkipped,
585
+ perRunSkipped,
586
+ pendingSkipped
587
+ };
413
588
  }
414
589
 
415
590
  // 非 freeze:跨 scope 对先停车(绝不进 LLM 裁决),同 scope 对照常走 LLM。
416
591
  let scopeParked = 0;
417
592
  for (const p of crossPairs) {
418
- if (parkPair(p, scopeReason(p))) scopeParked++;
593
+ if (parkPair(p, scopeReason(p)) === "fresh") scopeParked++;
419
594
  }
420
595
  if (samePairs.length === 0) {
421
596
  // 没有可送 LLM 的同 scope 对:可能一对都没检测到,也可能全被台账挡下了
@@ -443,6 +618,12 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
443
618
  // actionSet 已在候选构造前解析(跨类型候选需要它),这里只按档位选 prompt:
444
619
  // 默认档保持只有 conflict/keep 的窄 prompt,"full" 才开放六分支。
445
620
  const conflictPrompt = actionSet === "full" ? STR.prompts.conflictFull[language] : STR.prompts.conflict[language];
621
+ // reflectionUpdateEnabled=false:两份 conflict prompt 都教了模型怎么用 update
622
+ // (「信息部分过时 → update」那一支),只靠校验器拒会把每轮这份输出白付掉。
623
+ // 后缀与 dream 主链路共用同一条文案,两个入口对用户是同一个开关、同一种说法。
624
+ const conflictPromptGated = config.reflectionUpdateEnabled !== false
625
+ ? conflictPrompt
626
+ : conflictPrompt + STR.prompts.noUpdateSuffix[language];
446
627
  const runConflict = (batch, withEffort, tokens) => {
447
628
  conflictStreamFailure = "";
448
629
  const listText = batch.map((p) => STR.candidateConflicts[language](p)).join("\n\n");
@@ -470,7 +651,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
470
651
  maxTokens: tokens,
471
652
  ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
472
653
  messages: [
473
- { role: "system", content: [{ type: "text", text: conflictPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
654
+ { role: "system", content: [{ type: "text", text: conflictPromptGated }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
474
655
  { role: "user", content: [{ type: "text", text: fenceListBody(language, listText) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
475
656
  ]
476
657
  }, reportUsage, (reason) => { conflictStreamFailure = describeStreamFailure(reason); }));
@@ -534,6 +715,8 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
534
715
  // Issue #536 review(CodeRabbit ②):reflectionUpdateMaxPerRun 是**整轮**上限,
535
716
  // 分批后不能每批都拿整份额度——那样 N 批就是 N 倍上限。这里用的是本轮的剩余额度。
536
717
  maxUpdatePerRun: updateBudget,
718
+ // reflectionUpdateEnabled=false:update 单条跳过(同 dream 主链路口径)。
719
+ allowUpdate: config.reflectionUpdateEnabled !== false,
537
720
  minAgeHours: config.reflectionUpdateMinAgeHours,
538
721
  skipInvalid: config.dreamSkipInvalid !== false,
539
722
  allowCrossTypeMerge: config.allowCrossTypeMerge === true,
@@ -740,6 +923,9 @@ function phaseDemotion(service, config, logger, runId, signal = null) {
740
923
  const compressCut = Date.now() - compressDays * 86400000;
741
924
  const demoted = [];
742
925
  const archived = [];
926
+ // 被判定为「该降级」但 demoteToSummary 实际没动手的条数。单独报出来:
927
+ // 它正是「台账说降级了 N 条、实际一条没变」那个缺口,藏起来就等于没修。
928
+ let demoteSkipped = 0;
743
929
  // 政策轴在这里尤其要紧:下面两行会真改归档面(setArchived / demoteToSummary)。
744
930
  // 把过滤放在循环之外、而不是在循环体里 continue:一旦将来有人在循环体里加
745
931
  // 副作用(现已有),漏掉那条 continue 就是静默改到被按下的行。
@@ -749,6 +935,10 @@ function phaseDemotion(service, config, logger, runId, signal = null) {
749
935
  for (const m of withoutPaused(service, service.all().filter((m) => !isImportedRow(m)))) {
750
936
  if (signal?.aborted) break;
751
937
  if (m.archived || m.forgotten) continue;
938
+ // 已降级的行不必再进判定链:demoteToSummary 见到 _full_content 会直接返回,
939
+ // 但这行每轮仍会被选中、空转一次事务,再被下面无条件 push 进 demoted。
940
+ // 提前挡掉既省事务,也让「这一轮到底动了什么」从源头算得准(#603 台账)。
941
+ if (m._full_content) continue;
752
942
  const ref = m.last_accessed_at ?? m.created_at;
753
943
  if (!ref) continue;
754
944
  const t = new Date(ref).getTime();
@@ -771,14 +961,20 @@ function phaseDemotion(service, config, logger, runId, signal = null) {
771
961
  } else if (t < archiveCut) {
772
962
  // minRefTimeMs re-checks freshness inside demoteToSummary's transaction:
773
963
  // a recall touch landing after this snapshot must not demote the memory.
774
- service.demoteToSummary(m.id, makeSummary(m), { minRefTimeMs: archiveCut });
775
- demoted.push(m.id);
964
+ //
965
+ // 返回值必须看:demoteToSummary 在三种情况下返回 undefined(行不存在、
966
+ // 已降级、快照后被 recall 摸热),而这里原来无条件 push,于是台账把没发生
967
+ // 的降级记成发生了——live 库实测 12 轮里虚报 43%–99%(#603)。
968
+ const updated = service.demoteToSummary(m.id, makeSummary(m), { minRefTimeMs: archiveCut });
969
+ if (updated) demoted.push(m.id);
970
+ else demoteSkipped++;
776
971
  }
777
972
  }
778
973
  return {
779
974
  status: demoted.length || archived.length ? "ok" : "noop",
780
975
  demoted,
781
- archived
976
+ archived,
977
+ demoteSkipped
782
978
  };
783
979
  }
784
980
 
@@ -883,18 +1079,391 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
883
1079
  // applyDecisions 的 @param config。
884
1080
  // #512:runId 也必须给——这是 sleep 唯一的真实铸造路径,漏了它,铸造出来的
885
1081
  // pattern 就只能追到「sleep 这条管道」,追不到「哪一轮 sleep」(正是 #512 要补的口子)。
886
- const { applied, failures, conflicts } = applyDecisions(
1082
+ const { applied, merged, failures, conflicts } = applyDecisions(
887
1083
  decisions, service, logger, snapshot, { ...config, mintSource: "sleep", runId }
888
1084
  );
889
1085
  return {
890
- status: applied > 0 ? "ok" : "noop",
1086
+ // A pattern that hit an existing same-title row is refreshed in place, not
1087
+ // minted. Counting it as `ok` with `applied: 0` claims a mint that did not
1088
+ // happen; report the refresh separately (#619 audit round 6).
1089
+ status: applied > 0 ? "ok" : (merged > 0 ? "refreshed" : "noop"),
891
1090
  scanned: memories.length,
892
1091
  applied,
1092
+ merged,
893
1093
  failures,
894
1094
  conflicts
895
1095
  };
896
1096
  }
897
1097
 
1098
+ /**
1099
+ * Phase 5 — affective attribution (issue #619). The LLM reads recent memories and
1100
+ * names emotions, but the shape of what it may write is fixed here, not by the
1101
+ * prompt: an emotion tied to **one occasion** becomes an `affective_context`
1102
+ * relation carrying the attribution on the edge; only an emotion that recurs
1103
+ * across sleepAffectiveTraitMinSessions **independent sessions** may be minted as
1104
+ * a standing trait (a type=pattern memory, through the same
1105
+ * validateDecisions/applyDecisions channel every other mint uses).
1106
+ *
1107
+ * The whole point is the guardrail, so it lives in code and not in the prompt:
1108
+ * - the model may *claim* trait, but the claim only survives if provenance shows
1109
+ * enough distinct sessions. A single session is downgraded to occasion, never
1110
+ * dropped silently — dropped claims are counted and reported.
1111
+ * - person/target must resolve to entities that already exist; an emotion about
1112
+ * an unknown name is not evidence, it is a hallucination.
1113
+ * - evidence ids must be inside the scanned pool (same rule phasePatterns uses).
1114
+ * Off by default (sleepAffectiveEnabled) and a child of sleepModeEnabled: no
1115
+ * route, no entities, or the flag off all mean "skip", never "run anyway".
1116
+ */
1117
+ async function phaseAffective(ctx, service, config, logger, runId, signal = null) {
1118
+ if (config.sleepAffectiveEnabled !== true) return { status: "skipped", reason: "disabled" };
1119
+ const route = resolveSleepRoute(ctx, config, logger);
1120
+ if (!route) return { status: "skipped", reason: "no llm route" };
1121
+ const entities = service.listEntities({ limit: 1000 }) ?? [];
1122
+ if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
1123
+ const limit = config.sleepPatternMinMemories ?? 100;
1124
+ const poolQuery = {
1125
+ limit: Math.max(200, limit),
1126
+ includeForgotten: false,
1127
+ excludeTypes: ["document", "summary", "pattern"],
1128
+ excludeImported: true
1129
+ };
1130
+ const gather = (query) => withoutPaused(service, service.list(query));
1131
+ let pool = gather(poolQuery);
1132
+ if (pool.length < limit) pool = gather({ ...poolQuery, limit: null });
1133
+ const memories = [...pool].sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at))).slice(0, limit);
1134
+ if (memories.length === 0) return { status: "skipped", reason: "no memories" };
1135
+ const scannedIds = new Set(memories.map((m) => m.id));
1136
+ const byName = new Map();
1137
+ for (const e of entities) byName.set(e.name.toLowerCase(), e);
1138
+ const traitMinSessions = config.sleepAffectiveTraitMinSessions ?? 3;
1139
+ const maxRelations = config.sleepAffectiveMaxRelationsPerRun ?? 10;
1140
+ const maxTraits = config.sleepMaxPatternPerRun ?? 3;
1141
+ const language = langOf(config).startsWith("zh") ? "zh" : "en";
1142
+ const entityNames = entities.map((e) => e.name).slice(0, 200).join(", ");
1143
+ const listText = memories.map((m) => `id=${m.id} | time=${m.updated_at ?? ""} | type=${m.type} | title=${m.title} | content=${m.content}`).join("\n");
1144
+ let streamFailure;
1145
+ const runAffective = (tokens) => auditedSleepLlm(
1146
+ ctx, service, config, logger,
1147
+ {
1148
+ triggerSource: "sleep",
1149
+ operationType: "sleep_affective",
1150
+ modelId: `${route.provider}:${route.model}`,
1151
+ relatedMemoryIds: memories.map((m) => m.id),
1152
+ streamError: () => streamFailure,
1153
+ auditError: (out) => (parseJsonArray(out) ? null : "no json array in llm output"),
1154
+ metadataFrom: (out) => (parseJsonArray(out) ? null : parseFailureMeta(out))
1155
+ },
1156
+ (reportUsage) => streamText(ctx, {
1157
+ provider: route.provider,
1158
+ model: route.model,
1159
+ purpose: "sleep-affective",
1160
+ maxTokens: tokens,
1161
+ messages: [
1162
+ {
1163
+ role: "system",
1164
+ content: [{ type: "text", text: STR.prompts.affective[language].replaceAll("{{MIN_SESSIONS}}", String(traitMinSessions)) }],
1165
+ source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" }
1166
+ },
1167
+ { role: "user", content: [{ type: "text", text: fenceListBody(language, `${entityNames}\n---\n${listText}`) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
1168
+ ]
1169
+ }, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); })
1170
+ );
1171
+ const text = await withMaxTokensFallback(ctx, config.sleepMaxTokens ?? 2048, runAffective, () => streamFailure);
1172
+ if (text === undefined) return { status: "failed", error: "llm failed" };
1173
+ const items = parseJsonArray(text) ?? [];
1174
+ if (items.length === 0) return { status: "skipped", reason: "no attributions found" };
1175
+ const relations = [];
1176
+ const merges = [];
1177
+ const traits = [];
1178
+ const dropped = [];
1179
+ let downgradedCount = 0;
1180
+ const sessionCache = new Map();
1181
+ const sessionsFor = (memoryIds) => {
1182
+ const out = new Set();
1183
+ for (const id of memoryIds) {
1184
+ if (!sessionCache.has(id)) {
1185
+ let rows = [];
1186
+ try {
1187
+ rows = service.provenanceFor?.(id) ?? [];
1188
+ } catch {
1189
+ rows = [];
1190
+ }
1191
+ sessionCache.set(id, rows.map((r) => r.session_id).filter((s) => typeof s === "string" && s));
1192
+ }
1193
+ for (const s of sessionCache.get(id)) out.add(s);
1194
+ }
1195
+ return out;
1196
+ };
1197
+ for (const item of items) {
1198
+ if (signal?.aborted) break;
1199
+ if (!item || typeof item !== "object") continue;
1200
+ const person = byName.get(String(item.person ?? "").toLowerCase());
1201
+ const target = byName.get(String(item.target ?? "").toLowerCase());
1202
+ if (!person || !target || person.id === target.id) {
1203
+ dropped.push({ reason: "unknown entity", person: item.person, target: item.target });
1204
+ continue;
1205
+ }
1206
+ const evidence = (Array.isArray(item.evidence) ? item.evidence : []).filter((id) => scannedIds.has(id));
1207
+ if (evidence.length === 0) {
1208
+ dropped.push({ reason: "no real evidence", person: item.person, target: item.target });
1209
+ continue;
1210
+ }
1211
+ const emotion = String(item.emotion ?? "").trim();
1212
+ if (!emotion) {
1213
+ dropped.push({ reason: "no emotion", person: item.person, target: item.target });
1214
+ continue;
1215
+ }
1216
+ // The guardrail: a claimed trait is checked against provenance, and a claim
1217
+ // that does not survive is demoted — not discarded. Demoted claims are the
1218
+ // interesting number: they are exactly the mis-attributions the probe hunts.
1219
+ const claimedTrait = String(item.attribution ?? "").toLowerCase() === "trait";
1220
+ const sessions = sessionsFor(evidence);
1221
+ const downgraded = claimedTrait && sessions.size < traitMinSessions;
1222
+ const attribution = claimedTrait && !downgraded ? "trait" : "occasion";
1223
+ const record = {
1224
+ person: person.name,
1225
+ target: target.name,
1226
+ emotion,
1227
+ attribution,
1228
+ evidence,
1229
+ sessionCount: sessions.size,
1230
+ why: String(item.why ?? "").slice(0, 200),
1231
+ ...(downgraded ? { downgradedFrom: "trait" } : {})
1232
+ };
1233
+ if (downgraded) downgradedCount += 1;
1234
+ if (attribution === "trait") {
1235
+ traits.push(record);
1236
+ continue;
1237
+ }
1238
+ // #619 / Ephor: entity_relations 没有 UNIQUE 约束,saveRelation 每次
1239
+ // randomUUID() 新插一条。同一个 (person, target, 场合) 跑第二轮就会得到
1240
+ // 第二条一模一样的边——而探针会拿同一批段反复回放,重复边会让 sessionCount
1241
+ // 看起来比真实场合数多,主指标直接失真。
1242
+ //
1243
+ // 「同一场合」不能拿 evidence 有没有交集来判。两次判据都基于证据列表,
1244
+ // 而证据列表既不是场合本身、也不稳定:
1245
+ // ① 模型给 evidence 的顺序不定(parseJsonArray 也不排序),[m1,m2] 与
1246
+ // [m2,m1] 是同一场合的两种写法;
1247
+ // ② 同一会话里的两条不同记忆各说一句「他很焦虑」,证据天然不相交,可
1248
+ // 它们是同一个场合——那时该合并成一条边,而不是各写一条把边数抬高;
1249
+ // ③ 反过来,第一轮看到 1 条证据、第二轮攒到 2 条(含新会话),若判成重复
1250
+ // 就整条丢掉,边上 sessionCount 永远停在第一次那个数,将来读归因覆盖率
1251
+ // 会一直读到 1。
1252
+ // 所以键落在会话层:同一 (person, target, emotion) 且两边的会话集有交集,
1253
+ // 就是同一场合。命中时把 evidence 并集写回、按并集重算 sessionCount,而不是
1254
+ // 丢弃——被扩充的边记进 merged,让「这一轮其实补了证据」在返回值里看得见。
1255
+ // 这一步在 cap 之前:合并既有边不新增行,不该占名额。
1256
+ const currentSessions = sessions;
1257
+ const priorEdges = (service.getRelations(person.id) ?? []).filter((r) =>
1258
+ r.relation_type === "affective_context" && r.to_entity === target.id
1259
+ );
1260
+ let mergedInto = null;
1261
+ for (const edge of priorEdges) {
1262
+ const meta = edge.metadata && typeof edge.metadata === "object" ? edge.metadata : null;
1263
+ const priorEmotion = String(meta?.emotion ?? "").trim();
1264
+ if (priorEmotion !== emotion) continue;
1265
+ const priorEvidence = Array.isArray(meta?.evidence)
1266
+ ? meta.evidence
1267
+ : (edge.memory_id ? [edge.memory_id] : []);
1268
+ const priorSessions = sessionsFor(priorEvidence);
1269
+ // "Same occasion" is judged on session keys first. Only when either side
1270
+ // has no session key at all does it fall back to an evidence intersection:
1271
+ // memory_save writes memories without provenance (1671 of 8517 active rows
1272
+ // in the live database have none), so sessionsFor returns an empty set and
1273
+ // a session-only test would write the same edge again on every sleep pass,
1274
+ // inflating sessionCount past the real number of occasions.
1275
+ const sameOccasion = [...priorSessions].some((s) => currentSessions.has(s))
1276
+ || ((priorSessions.size === 0 || currentSessions.size === 0)
1277
+ && priorEvidence.some((id) => evidence.includes(id)));
1278
+ if (!sameOccasion) continue;
1279
+ mergedInto = { edge, priorEvidence, priorSessions };
1280
+ break;
1281
+ }
1282
+ if (mergedInto) {
1283
+ const { edge, priorEvidence, priorSessions } = mergedInto;
1284
+ const unionEvidence = [...new Set([...priorEvidence, ...evidence])];
1285
+ const unionSessions = new Set([...priorSessions, ...currentSessions]);
1286
+ const mergedMetadata = {
1287
+ ...(edge.metadata && typeof edge.metadata === "object" ? edge.metadata : {}),
1288
+ emotion,
1289
+ // 保留首个 attribution,但降级痕迹一旦有就留着——合并不能把
1290
+ // 「它曾被判成 trait 又被降级」这段历史抹掉。
1291
+ attribution: (edge.metadata && edge.metadata.attribution) || attribution,
1292
+ evidence: unionEvidence,
1293
+ sessionCount: unionSessions.size,
1294
+ lastMergedRunId: runId
1295
+ };
1296
+ if (record.downgradedFrom && !mergedMetadata.downgradedFrom) {
1297
+ mergedMetadata.downgradedFrom = record.downgradedFrom;
1298
+ }
1299
+ try {
1300
+ service.updateRelationMetadata?.(edge.id, mergedMetadata);
1301
+ merges.push({
1302
+ id: edge.id,
1303
+ from: person.name,
1304
+ to: target.name,
1305
+ emotion,
1306
+ evidence: unionEvidence.length,
1307
+ sessionCount: unionSessions.size
1308
+ });
1309
+ } catch (error) {
1310
+ logger?.warn?.(`dsh-mneme sleep: affective relation merge ${person.name}/${target.name} failed: ${String(error)}`);
1311
+ }
1312
+ continue;
1313
+ }
1314
+ if (relations.length >= maxRelations) {
1315
+ dropped.push({ reason: "relation cap", person: person.name, target: target.name });
1316
+ continue;
1317
+ }
1318
+ try {
1319
+ const saved = service.saveRelation({
1320
+ from_entity: person.id,
1321
+ to_entity: target.id,
1322
+ relation_type: "affective_context",
1323
+ memory_id: evidence[0],
1324
+ source: "llm",
1325
+ metadata: {
1326
+ emotion,
1327
+ attribution,
1328
+ why: record.why,
1329
+ evidence,
1330
+ sessionCount: sessions.size,
1331
+ ...(record.downgradedFrom ? { downgradedFrom: record.downgradedFrom } : {}),
1332
+ model: `${route.provider}:${route.model}`,
1333
+ runId
1334
+ }
1335
+ });
1336
+ relations.push({ id: saved?.id, from: person.name, to: target.name, emotion, attribution, ...(record.downgradedFrom ? { downgradedFrom: record.downgradedFrom } : {}) });
1337
+ } catch (error) {
1338
+ logger?.warn?.(`dsh-mneme sleep: affective relation ${person.name}/${target.name} failed: ${String(error)}`);
1339
+ }
1340
+ }
1341
+ // Traits ride the standard decision channel so they inherit every existing cap
1342
+ // and the #512 mint ledger. No new write path is introduced for them.
1343
+ let applied = 0;
1344
+ let refreshedTraits = 0;
1345
+ let failures = [];
1346
+ let conflicts = [];
1347
+ let ok = true;
1348
+ // Candidates beyond the cap are otherwise neither minted, demoted, nor
1349
+ // recorded: they simply vanish, while `traits` still counts them and a reader
1350
+ // concludes they were handled. Record them the same way `relation cap` records
1351
+ // edges, so "everything that disappears is accounted for" holds for traits.
1352
+ for (const t of traits.slice(maxTraits)) {
1353
+ dropped.push({ reason: "trait cap", person: t.person, target: t.target });
1354
+ }
1355
+ if (traits.length > 0) {
1356
+ const decisions = traits.slice(0, maxTraits).map((t) => ({
1357
+ action: "create",
1358
+ type: "pattern",
1359
+ title: STR.affectiveTrait.title[language](t),
1360
+ content: STR.affectiveTrait.content[language](t),
1361
+ importance: 3,
1362
+ evidence: t.evidence
1363
+ }));
1364
+ const snapshot = new Map();
1365
+ const validated = validateDecisions(decisions, snapshot, { maxCreatePerRun: maxTraits, allowedActions: ["create"] });
1366
+ ok = validated.ok;
1367
+ if (ok) {
1368
+ const result = applyDecisions(decisions, service, logger, snapshot, { ...config, mintSource: "sleep", runId });
1369
+ applied = result.applied;
1370
+ failures = result.failures;
1371
+ conflicts = result.conflicts;
1372
+ // A trait whose pattern was refreshed in place (same title, same row) is
1373
+ // not a new conclusion. Keep it out of `applied` and report it as a
1374
+ // refresh, otherwise two different targets would read as two minted
1375
+ // patterns while only one row exists (#619 audit round 6).
1376
+ refreshedTraits = result.merged ?? 0;
1377
+ // Ocasion edges observed before a disposition was confirmed do not get
1378
+ // rewritten — an edge records "this occasion was observed", which stays
1379
+ // true. But the edge and the pattern would then answer "how many
1380
+ // occasions" differently (edge.sessionCount vs pattern evidence), and a
1381
+ // coverage query reading both would have no way to tell they describe the
1382
+ // same thing. Mark them instead of mutating the count: the edge still
1383
+ // means one observed occasion, and `promotedToTrait` says that occasion is
1384
+ // now part of a confirmed disposition.
1385
+ for (const trait of traits.slice(0, maxTraits)) {
1386
+ const person = byName.get(String(trait.person ?? "").toLowerCase());
1387
+ const target = byName.get(String(trait.target ?? "").toLowerCase());
1388
+ if (!person || !target || person.id === target.id) continue;
1389
+ for (const edge of service.getRelations(person.id) ?? []) {
1390
+ if (edge.relation_type !== "affective_context" || edge.to_entity !== target.id) continue;
1391
+ const meta = edge.metadata && typeof edge.metadata === "object" ? edge.metadata : null;
1392
+ if (String(meta?.emotion ?? "").trim() !== trait.emotion) continue;
1393
+ if (meta?.promotedToTrait === true) continue;
1394
+ try {
1395
+ service.updateRelationMetadata?.(edge.id, {
1396
+ ...meta,
1397
+ promotedToTrait: true,
1398
+ traitSessionCount: trait.sessionCount
1399
+ });
1400
+ } catch (error) {
1401
+ logger?.warn?.(`dsh-mneme sleep: marking occasion ${edge.id} as promoted failed: ${String(error)}`);
1402
+ }
1403
+ }
1404
+ }
1405
+ }
1406
+ }
1407
+ // Aborting mid-pass is not "did nothing" and not "all done". Even when nothing
1408
+ // was written yet, "noop" would claim the whole pool was examined and found
1409
+ // empty — the loop broke at an arbitrary index, so it examined an unknown
1410
+ // prefix. Report it as aborted either way.
1411
+ if (signal?.aborted) {
1412
+ return {
1413
+ status: "aborted",
1414
+ reason: "user activity",
1415
+ scanned: memories.length,
1416
+ relations: relations.length,
1417
+ edgeWrites: relations.length,
1418
+ merged: merges.length,
1419
+ mergedList: merges,
1420
+ applied,
1421
+ refreshedTraits,
1422
+ traits: traits.length,
1423
+ downgraded: downgradedCount,
1424
+ dropped
1425
+ };
1426
+ }
1427
+ if (!ok) {
1428
+ return {
1429
+ status: "failed",
1430
+ error: "trait decisions rejected by validation",
1431
+ scanned: memories.length,
1432
+ relations: relations.length,
1433
+ edgeWrites: relations.length,
1434
+ merged: merges.length,
1435
+ mergedList: merges,
1436
+ applied: 0,
1437
+ refreshedTraits,
1438
+ traits: traits.length,
1439
+ downgraded: downgradedCount,
1440
+ dropped
1441
+ };
1442
+ }
1443
+ return {
1444
+ // "refreshed" is a real write that minted nothing: the trait pattern already
1445
+ // existed under the same title and was rewritten in place. Calling that
1446
+ // "noop" would say the phase examined the pool and found nothing to do.
1447
+ status: relations.length + merges.length + applied > 0 ? "ok" : (refreshedTraits > 0 ? "refreshed" : "noop"),
1448
+ scanned: memories.length,
1449
+ relations: relations.length,
1450
+ merged: merges.length,
1451
+ // #619: edges are real writes but not "applied decisions", so the receipt
1452
+ // would read `:0` for a run that did write. Count them separately instead
1453
+ // of folding them into `applied` (that would make the two numbers mean
1454
+ // different things depending on which phase produced them).
1455
+ edgeWrites: relations.length,
1456
+ mergedList: merges,
1457
+ applied,
1458
+ refreshedTraits,
1459
+ failures,
1460
+ conflicts,
1461
+ traits: traits.length,
1462
+ downgraded: downgradedCount,
1463
+ dropped
1464
+ };
1465
+ }
1466
+
898
1467
  /**
899
1468
  * Phase 4 — entity relation completion. Detects orphan entities (zero
900
1469
  * relations) and completes implied relations from memory co-occurrence:
@@ -958,7 +1527,9 @@ function deriveStatus(phases) {
958
1527
  const anyError = list.some((p) => p.status === "failed" || p.status === "error");
959
1528
  // Issue #126 review(Copilot):phase 自身可以是 degraded(合法子集已应用但有条目被
960
1529
  // 跳过),它同样算"有产出"——否则整轮会被误判成 noop,把真实变更报成空轮。
961
- const anyWork = list.some((p) => p.status === "ok" || p.status === "degraded");
1530
+ // #619:`refreshed` 也是一次真实写入(同题行原地刷新),只是没铸出新条目。把它
1531
+ // 排除在 anyWork 之外,整轮会被记成 noop,审计行又变回「跑了、什么都没干」。
1532
+ const anyWork = list.some((p) => p.status === "ok" || p.status === "degraded" || p.status === "refreshed");
962
1533
  const anyDegraded = list.some((p) => p.status === "degraded");
963
1534
  // Issue #529:被打断(用户恢复活动)既不是失败也不是"没活干"。相位层已经用
964
1535
  // aborted 区分了这两种,整轮层若回落到 noop,审计行就又把"没跑完"记成"跑过、
@@ -997,10 +1568,40 @@ export async function runSleep(ctx, service, config, logger, semantic = null, si
997
1568
  await attempt("demotion", () => phaseDemotion(service, config, logger, runId, signal));
998
1569
  await attempt("patterns", () => phasePatterns(ctx, service, config, logger, runId, signal));
999
1570
  await attempt("relations", () => phaseRelations(service, config, logger, runId, signal));
1571
+ // #619:情感归因排在 relations 之后——它要读实体与 provenance,先让孤立实体的
1572
+ // 结构边补完,再往上挂情绪轴;两相写的是不同 relation_type,不抢同一批对。
1573
+ await attempt("affective", () => phaseAffective(ctx, service, config, logger, runId, signal));
1000
1574
 
1001
1575
  const status = deriveStatus(phases);
1002
1576
  const route = resolveSleepRoute(ctx, config, logger);
1003
- const totalApplied = Object.values(phases).reduce((n, p) => n + (Number.isInteger(p?.applied) ? p.applied : 0), 0);
1577
+ // #619: a phase may write rows that are not "applied decisions" — the
1578
+ // affective phase inserts relation edges directly — and it may also extend an
1579
+ // existing edge instead of inserting one. Both are store writes, and the
1580
+ // receipt has to answer "did this run change anything", so both count. A run
1581
+ // whose only effect was extending an edge would otherwise read `:0`, i.e.
1582
+ // "ran and did nothing", which is exactly what a probe must never see.
1583
+ //
1584
+ // The per-kind numbers stay apart in `phases` (`applied` / `edgeWrites` /
1585
+ // `merged` / `refreshedTraits`); the receipt is a single write count on
1586
+ // purpose, because buildReceipt/parseReceipt are an eight-field contract
1587
+ // shared with organize.js and four test files. Detail lives in
1588
+ // dream_runs.phases JSON, not in the receipt.
1589
+ //
1590
+ // #619 / Ephor 第七轮 ①:`refreshedTraits` 必须计入。它是「同一标题的 pattern
1591
+ // 被原地重写」的条数——库确实变了,但没有铸新条目,所以它既不在 `applied` 里
1592
+ // (那会把刷新谎报成铸造),也不在 `merged` 里(affective 相位的 `merged` 是
1593
+ // **边**合并数 `merges.length`,同名不同义)。漏掉它的后果实测过:第二轮
1594
+ // `phase.status=refreshed`、`refreshedTraits=1`,整轮 receipt 第 7 段仍是 0,
1595
+ // 于是「相位说改了、整轮说没改」,正是上一轮刚修掉的「改了库读成 0」形态。
1596
+ const totalApplied = Object.values(phases).reduce(
1597
+ (n, p) =>
1598
+ n +
1599
+ (Number.isInteger(p?.applied) ? p.applied : 0) +
1600
+ (Number.isInteger(p?.edgeWrites) ? p.edgeWrites : 0) +
1601
+ (Number.isInteger(p?.merged) ? p.merged : 0) +
1602
+ (Number.isInteger(p?.refreshedTraits) ? p.refreshedTraits : 0),
1603
+ 0
1604
+ );
1004
1605
  // Issue #126(与 #104 同向):把各 phase 的跳过明细汇总成一行审计数据,带 phase
1005
1606
  // 名(同一次 run 里 conflicts 与 patterns 都可能产出 skipped)。全空时写 NULL,
1006
1607
  // 不落空数组——审计行只记真实发生过的跳过。