@modusensus/dsh-mneme 0.12.0 → 0.14.0-beta.1

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 (182) hide show
  1. package/README.md +51 -20
  2. package/bin/dsh-mneme-choro.mjs +9 -1
  3. package/docs/CONFIGURATION.md +47 -6
  4. package/docs/DIRECTORIES.md +114 -0
  5. package/docs/DISTILL.md +64 -0
  6. package/docs/DSH-GO.md +97 -0
  7. package/docs/ENTITIES.md +5 -3
  8. package/docs/FORGET.md +77 -0
  9. package/docs/KB.md +110 -0
  10. package/docs/SLEEP.md +28 -3
  11. package/docs/STORAGE.md +23 -0
  12. package/lib/api-standalone.js +4 -1
  13. package/lib/api.js +788 -30
  14. package/lib/attachment-text.js +122 -0
  15. package/lib/choro-command.js +9 -2
  16. package/lib/client.js +4627 -1409
  17. package/lib/config.js +166 -1
  18. package/lib/continuity.js +7 -6
  19. package/lib/diary/auto.js +190 -0
  20. package/lib/diary/compose.js +440 -0
  21. package/lib/diary/dir.js +59 -0
  22. package/lib/diary/export.js +97 -0
  23. package/lib/diary/index.js +26 -0
  24. package/lib/diary/library.js +189 -0
  25. package/lib/diary/qq-inbox.js +142 -0
  26. package/lib/diary/rollover.js +61 -0
  27. package/lib/diary/session-provider.js +122 -0
  28. package/lib/dream/decisions.js +95 -6
  29. package/lib/dream/sleep.js +760 -19
  30. package/lib/dream/triage.js +139 -0
  31. package/lib/dream.js +168 -21
  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 +22 -11
  44. package/lib/index.js +123 -3
  45. package/lib/inject.js +203 -20
  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 +98 -4
  50. package/lib/peak-hours.js +57 -2
  51. package/lib/semantic.js +347 -60
  52. package/lib/service.js +268 -69
  53. package/lib/settings.js +128 -15
  54. package/lib/store.js +585 -26
  55. package/lib/summarize.js +38 -11
  56. package/lib/tools.js +35 -4
  57. package/lib/user-settings.js +128 -32
  58. package/lib/vector-index.js +76 -6
  59. package/lib/wiki/claims.js +267 -0
  60. package/lib/wiki/cli-hint.js +90 -0
  61. package/lib/wiki/queries.js +282 -2
  62. package/lib/wiki/render.js +74 -0
  63. package/lib/wiki/t0.js +43 -6
  64. package/lib/wiki/t1-jobs.js +232 -0
  65. package/lib/wiki/t1.js +145 -32
  66. package/package.json +39 -1
  67. package/scripts/release-docs.mjs +7 -1
  68. package/scripts/release-notes.mjs +4 -1
  69. package/src/api-standalone.js +4 -1
  70. package/src/api.js +788 -30
  71. package/src/attachment-text.js +122 -0
  72. package/src/choro-command.js +9 -2
  73. package/src/config.js +166 -1
  74. package/src/continuity.js +7 -6
  75. package/src/diary/auto.js +190 -0
  76. package/src/diary/compose.js +440 -0
  77. package/src/diary/dir.js +59 -0
  78. package/src/diary/export.js +97 -0
  79. package/src/diary/index.js +26 -0
  80. package/src/diary/library.js +189 -0
  81. package/src/diary/qq-inbox.js +142 -0
  82. package/src/diary/rollover.js +61 -0
  83. package/src/diary/session-provider.js +122 -0
  84. package/src/dream/decisions.js +95 -6
  85. package/src/dream/sleep.js +760 -19
  86. package/src/dream/triage.js +139 -0
  87. package/src/dream.js +168 -21
  88. package/src/dsh-go/host.js +162 -0
  89. package/src/dsh-go/panel.js +2091 -0
  90. package/src/dsh-go/text.js +342 -0
  91. package/src/dsh-go/views/common.js +68 -0
  92. package/src/dsh-go/views/entity.js +48 -0
  93. package/src/dsh-go/views/memory.js +198 -0
  94. package/src/dsh-go/views/root.js +30 -0
  95. package/src/dsh-go/views/settings.js +398 -0
  96. package/src/dsh-go/views/status.js +51 -0
  97. package/src/embedding.js +42 -8
  98. package/src/entities/extractor.js +36 -7
  99. package/src/hot-memory.js +22 -11
  100. package/src/index.js +123 -3
  101. package/src/inject.js +203 -20
  102. package/src/kb/index.js +309 -0
  103. package/src/kb/routes.js +220 -0
  104. package/src/kb/tools.js +148 -0
  105. package/src/lang.js +98 -4
  106. package/src/peak-hours.js +57 -2
  107. package/src/semantic.js +347 -60
  108. package/src/service.js +268 -69
  109. package/src/settings.js +128 -15
  110. package/src/store.js +585 -26
  111. package/src/summarize.js +38 -11
  112. package/src/tools.js +35 -4
  113. package/src/user-settings.js +128 -32
  114. package/src/vector-index.js +76 -6
  115. package/src/wiki/claims.js +267 -0
  116. package/src/wiki/cli-hint.js +90 -0
  117. package/src/wiki/queries.js +282 -2
  118. package/src/wiki/render.js +74 -0
  119. package/src/wiki/t0.js +43 -6
  120. package/src/wiki/t1-jobs.js +232 -0
  121. package/src/wiki/t1.js +145 -32
  122. package/test/api-routes-gaps.test.js +63 -0
  123. package/test/api.test.js +385 -4
  124. package/test/attachment-text.test.js +140 -0
  125. package/test/bm25-corpus-cache.test.js +145 -0
  126. package/test/capacity-warning.test.js +262 -0
  127. package/test/client.test.js +1051 -185
  128. package/test/conflict-freeze.test.js +45 -1
  129. package/test/conflict-ledger-enforce.test.js +452 -0
  130. package/test/conflict-ledger.test.js +242 -0
  131. package/test/continuity.test.js +8 -1
  132. package/test/contrast.test.js +124 -12
  133. package/test/diary-library.test.js +187 -0
  134. package/test/diary.test.js +576 -0
  135. package/test/dream-baseline-clamp.test.js +174 -0
  136. package/test/dream-maxtokens-fallback.test.js +3 -3
  137. package/test/dream.test.js +62 -1
  138. package/test/dsh-go-host.test.js +134 -0
  139. package/test/dsh-go-panel-actions.test.js +854 -0
  140. package/test/dsh-go-panel.test.js +251 -0
  141. package/test/embedding-ready.test.js +33 -3
  142. package/test/entities.test.js +73 -0
  143. package/test/forget-corrections.test.js +70 -0
  144. package/test/graph-api.test.js +124 -0
  145. package/test/hot-memory.test.js +48 -0
  146. package/test/image-memory.test.js +171 -0
  147. package/test/inject-parent-gate.test.js +6 -3
  148. package/test/inject.test.js +59 -0
  149. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  150. package/test/kb-api-wiring.test.js +128 -0
  151. package/test/kb-collections.test.js +272 -0
  152. package/test/kb-routes.test.js +379 -0
  153. package/test/mint-source.test.js +26 -0
  154. package/test/panel-chart-scale.test.js +90 -0
  155. package/test/panel-tokens.test.js +245 -0
  156. package/test/peak-hours-observability.test.js +70 -0
  157. package/test/persona-api.test.js +3 -3
  158. package/test/persona-by-session.test.js +568 -0
  159. package/test/persona-settings.test.js +7 -7
  160. package/test/reflection-update-gate.test.js +129 -0
  161. package/test/reindex-backfill.test.js +334 -0
  162. package/test/reindex-scheduler.test.js +369 -0
  163. package/test/release-docs.test.js +20 -0
  164. package/test/release-notes.test.js +25 -0
  165. package/test/scope-candidates.test.js +249 -0
  166. package/test/security-doc.test.js +10 -0
  167. package/test/sleep-action-set.test.js +14 -0
  168. package/test/sleep-heat.test.js +49 -0
  169. package/test/sleep.test.js +635 -0
  170. package/test/standalone-api.test.js +24 -0
  171. package/test/store.test.js +20 -0
  172. package/test/summarize.test.js +26 -0
  173. package/test/user-settings-segments.test.js +528 -0
  174. package/test/vector-index.test.js +120 -6
  175. package/test/wiki-api.test.js +463 -1
  176. package/test/wiki-claims.test.js +194 -0
  177. package/test/wiki-cli-hint.test.js +118 -0
  178. package/test/wiki-provenance.test.js +202 -0
  179. package/test/wiki-queries.test.js +128 -0
  180. package/test/wiki-t0.test.js +77 -1
  181. package/test/wiki-t1-jobs.test.js +125 -0
  182. package/test/wiki-t1.test.js +226 -1
@@ -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
@@ -20,6 +23,8 @@ import { STR, langOf } from "../lang.js";
20
23
  import { randomUUID, createHash } from "node:crypto";
21
24
  import { validateDecisions, applyDecisions, ACTIONS } from "./decisions.js";
22
25
  import { findPotentialConflicts, cosineSimilarity } from "./clustering.js";
26
+ import { triagePair, recordLedgerTriage } from "./triage.js";
27
+ import { supersedeInfoFirstPair } from "./decisions.js";
23
28
  import { scopeKeyOf } from "../scope.js";
24
29
  import { buildReceipt, describeStreamFailure, resolveDreamEffort, withEffortFallback, withMaxTokensFallback, maintainIndexAfterDream, fenceListBody, parseFailureMeta } from "../dream.js";
25
30
  import { computeHeat } from "../heat.js";
@@ -356,6 +361,11 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
356
361
  // · 只筛同 scope 对:跨 scope 对本来就要停车进人工队列,不能被台账吞掉;
357
362
  // · freeze 模式不筛:那一档只停车不送 LLM,台账与它无关(ledgerSkipped 记 0);
358
363
  // · ledgerSkipped 在这里一次数清,后面 candidates 直接取 samePairs,不再二次过滤。
364
+ // ⚠️ Issue #616:这个判据把「未标注」当成「同 scope」。scopeKeyOf 把 NULL/空串
365
+ // 归一成 null,于是两条都没标注时 null !== null 为 false,配对算同 scope、可以进
366
+ // 自动池。但「归属未知」和「归属相同」不是一回事:按 v0.8.1 #170,前者本该是不可
367
+ // 自动的范围。live 库活跃 8259 行里 2710 行(32.8%)两个 scope 全 NULL,受影响的
368
+ // 面不小。修法是把「任一未标注」显式判为不可自动,而不是继续让 null 自我相等。
359
369
  const isCrossScopePair = (p) =>
360
370
  scopeKeyOf(p.a.agent_scope) !== scopeKeyOf(p.b.agent_scope)
361
371
  || scopeKeyOf(p.a.workspace_scope) !== scopeKeyOf(p.b.workspace_scope);
@@ -368,7 +378,18 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
368
378
  if (!rec?.judged_at) return false;
369
379
  return (p.a.updated_at ?? "") <= rec.judged_at && (p.b.updated_at ?? "") <= rec.judged_at;
370
380
  };
371
- const selectionPairs = config.conflictFreezeEnabled === true
381
+ // Issue #641(beta 第二阶段):分诊账本的档位。#616 点名的父子绑定不继承——
382
+ // enforce 与 freeze 同开时 freeze 让位(本相位降级 shadow + warn):freeze 照常
383
+ // 停车(shadow 语义 = 只记账零处置),绝不出现两套处置语义同时生效。宁可少处置,
384
+ // 不在打架的配置下跑。
385
+ let ledgerMode = config.conflictLedgerMode ?? "off";
386
+ if (ledgerMode === "enforce" && config.conflictFreezeEnabled === true) {
387
+ logger?.warn?.("dsh-mneme sleep: conflictLedgerMode=enforce coexists with conflictFreezeEnabled — degrading to shadow; disable one of them");
388
+ ledgerMode = "shadow";
389
+ }
390
+ // alreadyJudged 预筛只为省 LLM:freeze 与 enforce 都不走 LLM,筛不筛不影响成本,
391
+ // 不筛还能让 T2/T3/T4 的每轮观察持续刷新账本(tier 可能随内容更新而变)。
392
+ const selectionPairs = config.conflictFreezeEnabled === true || ledgerMode === "enforce"
372
393
  ? fresh
373
394
  : fresh.filter((p) => isCrossScopePair(p) || !alreadyJudged(p));
374
395
  const ledgerSkipped = fresh.length - selectionPairs.length;
@@ -382,6 +403,115 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
382
403
  selected.push(p);
383
404
  }
384
405
 
406
+ // Issue #641(beta 第一阶段,shadow 档):分诊账本。对 selected 全量做确定性
407
+ // 分诊(dup / cross-scope-dup / evolution / conflict,判据唯一实现见 triage.js)
408
+ // 并落 conflict_ledger。shadow = 只记账零处置:下面 freeze/LLM 路径一个字节都
409
+ // 不变——shadow 的存在意义就是让「分诊分布是否复现真实库 46/20/5/27」在真实
410
+ // 运行里可测,作为切 enforce 前的预注册证据。记账失败只 warn 计数,绝不反噬
411
+ // 相位(与 conflict_reviewed 同一条 best-effort 纪律)。
412
+ // 记账时点在 used 去重之后:被去重压掉的那半边对(同一条记忆配给了更高相似的
413
+ // 对手)本来就不是这一轮的候选,观察它们只会让分母虚高。
414
+ const ledgerTriage = ledgerMode !== "off"
415
+ ? await recordLedgerTriage(service, runId, selected, {
416
+ dupMinSimilarity: config.conflictLedgerDupMinSimilarity,
417
+ logger,
418
+ // 分块记账的取消检查:用户活动打断时停止记账,余下的对下一轮重见
419
+ signal
420
+ })
421
+ : null;
422
+
423
+ // Issue #641(beta 第二阶段,enforce 档):确定性处置接线。T1(dup)走
424
+ // supersedeInfoFirstPair(与冻结分支自动档同一条契约,decisions.js 共享实现);
425
+ // T1 走信息量优先 supersede、T3 走取代链(superseded_by 指向,不归档)、
426
+ // T2/T4 记账即处置——不停车、不送 LLM。人审队列与 LLM 裁决在本档下都不再
427
+ // 消费冲突候选,conflict_pending 恒空。每轮上限沿用
428
+ // sleepConflictMaxPairsPerRun:处置虽不走 LLM,归档/落链仍是写动作,写预算
429
+ // 与 freeze/auto 两档同口径。T4 的检索时消解(fuseRecall 矛盾感知 + 注入
430
+ // 标注)是后续 PR,本档先把人审摘出去。
431
+ if (ledgerMode === "enforce") {
432
+ const perRunCap = config.sleepConflictMaxPairsPerRun ?? 60;
433
+ const pool = perRunCap > 0 ? selected.slice(0, perRunCap) : selected;
434
+ let t1Resolved = 0;
435
+ let t1Failed = 0;
436
+ let t3Chained = 0;
437
+ const t1VectorFixups = [];
438
+ for (const p of pool) {
439
+ const verdict = triagePair(p.a, p.b, p.similarity, { dupMinSimilarity: config.conflictLedgerDupMinSimilarity });
440
+ if (verdict.tier === "evolution") {
441
+ // T3 取代链(beta 第三阶段):旧行 superseded_by → 新行,检索降权,
442
+ // **不归档**——演进不是输赢,旧值仍是「曾经为真」的可追溯历史(与
443
+ // supersede 动作的归档语义不同)。新者胜对演进成立:间隔 ≥7 天的
444
+ // 同主题对,新记载才反映现状(服务器时钟,内容声称的时间不做键)。
445
+ const newer = (p.a.created_at ?? "") >= (p.b.created_at ?? "") ? p.a : p.b;
446
+ const older = newer === p.a ? p.b : p.a;
447
+ try {
448
+ if (service.supersedeChain(older.id, newer.id)) {
449
+ t3Chained += 1;
450
+ // 先落观察行再推进状态(#650 评审):enforce 档下 recordLedgerTriage 只会
451
+ // 把这一对写成 open,UPDATE 于此才有行可命中;顺序反了就是静默落空。
452
+ service.saveConflictLedgerEntry({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, tier: verdict.tier, similarity: p.similarity, signals: verdict.signals });
453
+ service.setConflictLedgerStatus(p.a.id, p.b.id, "superseded");
454
+ }
455
+ } catch (error) {
456
+ logger?.warn?.(`dsh-mneme sleep: ledger T3 chain failed for ${p.a.id}/${p.b.id}: ${String(error)}`);
457
+ }
458
+ continue;
459
+ }
460
+ if (verdict.tier !== "dup") continue;
461
+ let res = null;
462
+ try {
463
+ res = supersedeInfoFirstPair(p, service, logger, config);
464
+ } catch (error) {
465
+ logger?.warn?.(`dsh-mneme sleep: ledger T1 supersede failed for ${p.a.id}/${p.b.id}: ${String(error)}`);
466
+ }
467
+ if (res?.applied) {
468
+ t1Resolved += 1;
469
+ // winner/loser 用 helper 判定的真实方向(信息量优先可能翻转 p.a/p.b)
470
+ t1VectorFixups.push({ action: "supersede", winner: res.winnerId, loser: res.loserId });
471
+ try {
472
+ // 账本状态推进 + 机器台账照记(与自动档同款 outcome 口径)——自动处置
473
+ // 也要能事后回答「这一对什么时候、被谁判的」。先落观察行(#650 评审):
474
+ // 这一对未必已在账本里,直接 UPDATE 会命中 0 行而静默失记。
475
+ service.saveConflictLedgerEntry({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, tier: verdict.tier, similarity: p.similarity, signals: verdict.signals });
476
+ service.setConflictLedgerStatus(p.a.id, p.b.id, "superseded");
477
+ service.saveConflictReviewed?.({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, outcome: "supersede", judged_at: new Date().toISOString() });
478
+ } catch (error) {
479
+ logger?.warn?.(`dsh-mneme sleep: ledger bookkeeping after T1 supersede failed for ${p.a.id}/${p.b.id}: ${String(error)}`);
480
+ }
481
+ } else {
482
+ t1Failed += 1;
483
+ }
484
+ }
485
+ // 败者向量清理:T1 的 supersede 不在下面 LLM 通路的 maintainIndexAfterDream
486
+ // 清单里(enforce 已早退),必须在早退前自己清——已归档行留向量等于检索
487
+ // 层「看得见摸不着」。
488
+ if (t1VectorFixups.length > 0 && semantic?.embedder && semantic?.vectorIndex) {
489
+ try {
490
+ await maintainIndexAfterDream(t1VectorFixups, service, semantic);
491
+ } catch (error) {
492
+ logger?.warn?.(`dsh-mneme sleep: ledger T1 index maintenance failed: ${String(error)}`);
493
+ }
494
+ }
495
+ if (selected.length > pool.length) {
496
+ logger?.warn?.(`dsh-mneme sleep: conflict ledger enforce capped at ${pool.length}/${selected.length} pairs this run`);
497
+ }
498
+ return {
499
+ status: t1Failed > 0 ? "degraded" : t1Resolved > 0 || t3Chained > 0 ? "ok" : "noop",
500
+ clean: t1Failed === 0,
501
+ frozen: 0,
502
+ scopeCandidates: 0,
503
+ pairs: selected.length,
504
+ batches: 0,
505
+ ledgerSkipped,
506
+ // applied 是整轮回执的唯一写库口径(runSleep 的 totalApplied 只认这个键)。
507
+ // 缺了它,本档真实发生的 T1 归档与 T3 落链在 receipt / dream_runs.applied
508
+ // 里全是 0 ——「本相位说改了、整轮说没改」(#650 评审,#619 同族)。
509
+ applied: t1Resolved + t3Chained,
510
+ enforced: { t1Resolved, t1Failed, t3Chained },
511
+ ...(ledgerTriage ? { ledgerTriage } : {})
512
+ };
513
+ }
514
+
385
515
  // v0.8.1(issue #170 第 2 步):跨 scope 相似对分流。同一内容落在两个归属下
386
516
  // (任一维标注键不等即算,NULL=未标注=全局也参与比较——「全局副本 + 专属副本
387
517
  // 并存」正是收窄/提升裁决的典型候选)不能自动裁决:归档败者可能销毁该内容在
@@ -389,33 +519,203 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
389
519
  // (专属 reason),同 scope 对维持既有路径(freeze 停车 / LLM 裁决)。
390
520
  const crossPairs = selected.filter(isCrossScopePair);
391
521
  const samePairs = selected.filter((p) => !isCrossScopePair(p));
522
+ // 四态:fresh(真的新插了一行)/ existing(这一对已在队列里)/ suppressed
523
+ // (已人工裁决且两侧未变,复核项 4)/ failed(写库抛错)。旧代码把
524
+ // 「返回非 null」一律当停车成功,existing 因此每轮虚报一遍 frozen(#603)。
392
525
  const parkPair = (p, reason) => {
393
526
  try {
394
- // 返回 undefined = 该对已被人工裁决且两侧内容未变(复核项 4)——不算停车。
395
- return service.saveConflictPending({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, reason }) != null;
527
+ const parked = service.saveConflictPending({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, reason, similarity: p.similarity });
528
+ if (parked == null) return "suppressed";
529
+ return parked.fresh === true ? "fresh" : "existing";
396
530
  } catch (error) {
397
531
  logger?.warn?.(`dsh-mneme sleep: failed to park conflict ${p.a.id}/${p.b.id}: ${String(error)}`);
398
- return false;
532
+ return "failed";
399
533
  }
400
534
  };
401
535
  const scopeReason = (p) => STR.scopeCandidateReason[language](p.similarity.toFixed(2));
402
536
  const simReason = (p) => STR.similarityReason[language](p.similarity.toFixed(2));
403
537
 
538
+ // 待审对的键集合(与 pairKey 同形)。只用于 freeze 分支的「先滤已入队」,
539
+ // 分页拉全量:队列规模不受 50 条默认页长限制。
540
+ const loadPendingPairKeys = () => {
541
+ const keys = new Set();
542
+ if (typeof service.listConflictPending !== "function") return keys;
543
+ const pageSize = 500;
544
+ for (let offset = 0; ; offset += pageSize) {
545
+ const page = service.listConflictPending({ limit: pageSize, offset, includeResolved: false }) ?? [];
546
+ for (const row of page) keys.add(`${row.memory_a}\u0000${row.memory_b}`);
547
+ if (page.length < pageSize) break;
548
+ }
549
+ return keys;
550
+ };
551
+
404
552
  // Freeze mode: park pairs for manual review, no LLM required. Cross-scope
405
553
  // pairs carry the dedicated scope-candidate reason so the queue reads them
406
554
  // as ownership decisions rather than plain duplicates.
555
+ //
556
+ // Issue #603 第 1 层:这条分支此前绕过全部三个上限、直接裸跑 selected。
557
+ // · 先滤已入队再截断——裸 slice 会饥饿:上限恒小于候选数时,前 N 个名额
558
+ // 每轮都被同一批已在队列里的对占住,后面的对永远轮不上;
559
+ // · 接预算:conflictFreezeMaxPending 决定队列还能装多少,
560
+ // sleepConflictMaxPairsPerRun 决定一轮最多处理多少,取两者较小值;
561
+ // · 只把 fresh 计入 frozen,已入队的对不再虚报。
562
+ //
563
+ // ⚠️ Issue #616:下面这段 freeze 分支同时也是「自动裁决档」的唯一宿主——
564
+ // conflictAutoResolveEnabled 的全部逻辑(autoEnabled / autoPool / autoResolved)
565
+ // 都在这对花括号里面。两个配置键在面板上看是平行的,实际是父子:把 freeze
566
+ // 关掉,自动档会跟着静默消失,没有任何提示。拆开之前别把它们当独立开关用。
407
567
  if (config.conflictFreezeEnabled === true) {
568
+ const maxPending = Number.isInteger(config.conflictFreezeMaxPending) ? config.conflictFreezeMaxPending : 100;
569
+ const pendingNow = typeof service.countConflictPending === "function" ? (service.countConflictPending() ?? 0) : 0;
570
+ const room = Math.max(0, maxPending - pendingNow);
571
+ const pendingKeys = loadPendingPairKeys();
572
+ const unparked = selected.filter((p) => !pendingKeys.has(pairKey(p)));
573
+ const withinRoom = room > 0 ? unparked.slice(0, room) : [];
574
+ const pendingSkipped = unparked.length - withinRoom.length;
575
+ const perRunCap = config.sleepConflictMaxPairsPerRun ?? 60;
576
+ const pool = perRunCap > 0 ? withinRoom.slice(0, perRunCap) : withinRoom;
577
+ // 只报「有位置但被每轮上限截住」的那部分:队列满时再追一轮也没用,
578
+ // 报成 backlog 会让调度器空转(catchup 上限 24 轮)。
579
+ const perRunSkipped = withinRoom.length - pool.length;
580
+ // Issue #603 第 4 层(自动裁决档,默认关):相似度极高的**同 scope** 对不再
581
+ // 进人工队列,直接保留信息量更大的那条、归档另一条。人只看模糊地带。
582
+ //
583
+ // 为什么阈值留在配置里而不是写死 0.95:这一档减的是峰值,
584
+ // 不是把队列清零——把阈值当常量会让人以为「开了就没事了」。
585
+ //
586
+ // 为什么跨 scope 永不自动:归属是用户的决定(v0.8.1 #170),归档败者可能
587
+ // 销毁某归属下的唯一副本。这条约束比减负重要。
588
+ //
589
+ // ⚠️ Issue #616:这条守卫有一个洞。isCrossScopePair 走 scopeKeyOf,NULL 归一
590
+ // 成 null,null !== null 判为 false —— 「未标注」因此被当成「同 scope」混进
591
+ // autoPool。live 库活跃 8259 行里 2710 行(32.8%)两个 scope 全 NULL。改判据
592
+ // 之前,别把这一档当成「归属安全」。
593
+ //
594
+ // 为什么用 supersede 而不是 conflict:conflict 会把「(已否决旧信息:…)」
595
+ // 追加到**赢家**正文,而赢家正是留下来的那条;近重复对再叠一段互相引用的
596
+ // 注记,等于用冗余污染幸存记忆。supersede 把可追溯性留在被归档的一侧,
597
+ // 赢家正文一字不动(#126 的原设计)。
598
+ const autoEnabled = config.conflictAutoResolveEnabled === true;
599
+ const autoMin = typeof config.conflictAutoResolveMinSimilarity === "number"
600
+ ? config.conflictAutoResolveMinSimilarity
601
+ : 0.95;
602
+ const autoPool = [];
603
+ const parkPool = [];
604
+ for (const p of pool) {
605
+ const eligible = autoEnabled
606
+ && !isCrossScopePair(p)
607
+ && typeof p.similarity === "number"
608
+ && p.similarity >= autoMin;
609
+ (eligible ? autoPool : parkPool).push(p);
610
+ }
611
+ let autoResolved = 0;
612
+ let autoFailed = 0;
613
+ // 这一档不走 LLM,没有批次时间戳可借(judgedAt 在下面那条 LLM 通路里才定义),
614
+ // 自己取一个:台账要能回答「什么时候判的」,不能留空。
615
+ const autoJudgedAt = new Date().toISOString();
616
+ // 保留**信息量更大**的那条,不是更新的那条。
617
+ //
618
+ // 「保留新的」看着合理,实测是错的:live 库 >=0.95 的 20 对里,11 对(55%)
619
+ // 新的那条比旧的短,平均短 132 字;降到 0.88 的 213 对里 122 对(57%)如此。
620
+ // 典型例子:「用户同时开 web profile 和 desktop profile 时出现开不了新对话/
621
+ // 会话异常,用户自己…」(371 字,旧)被压成「用户同时开 web profile 和
622
+ // desktop profile 的 dsh-mneme 时开不了新对话」(307 字,新)——蒸馏时压缩过
623
+ // 的一版反而更新。留新条等于把更完整的那条归档掉。
624
+ //
625
+ // 这与 mem0 的裁决规则一致:`configs/prompts.py:217` 明写「两句话说的是同一
626
+ // 件事时,留信息量最大的那条,不是留最新的」。cognee 反过来(「取更新的」),
627
+ // 那是反例——照抄会把「0:00 前必须躺下」削成残缺版。
628
+ //
629
+ // 长度相同才算「信息量相当」,此时退回更新时间,再退回 id:三级兜底保证同一
630
+ // 对每次判出同一个赢家(否则重放会来回翻,归档的可能是刚保住的那条)。
631
+ const infoFirst = (p) => {
632
+ const an = p.a.content?.length ?? 0;
633
+ const bn = p.b.content?.length ?? 0;
634
+ if (an !== bn) return an > bn ? [p.a, p.b] : [p.b, p.a];
635
+ const au = p.a.updated_at ?? "";
636
+ const bu = p.b.updated_at ?? "";
637
+ if (au !== bu) return au > bu ? [p.a, p.b] : [p.b, p.a];
638
+ return p.a.id > p.b.id ? [p.a, p.b] : [p.b, p.a];
639
+ };
640
+ for (const p of autoPool) {
641
+ const [winner, loser] = infoFirst(p);
642
+ const snapshot = new Map();
643
+ for (const m of [winner, loser]) {
644
+ snapshot.set(m.id, { updated_at: m.updated_at, content: m.content, title: m.title });
645
+ }
646
+ // trustEpistemicWeighting 显式关掉:它会在 applySupersede 里按 epistemic_status
647
+ // 交换赢家,那会让「留信息量更大的那条」这条契约随数据漂移。自动档的承诺
648
+ // 必须是确定的。
649
+ const { applied, conflicts } = applyDecisions(
650
+ [{ action: "supersede", winner: winner.id, loser: loser.id }],
651
+ service, logger, snapshot, { ...config, trustEpistemicWeighting: false }
652
+ );
653
+ if (applied > 0) {
654
+ autoResolved++;
655
+ // 记进机器侧台账:自动动作也要能事后回答「这一对什么时候、被谁判的」。
656
+ try {
657
+ service.saveConflictReviewed?.({
658
+ run_id: runId, memory_a: p.a.id, memory_b: p.b.id,
659
+ outcome: "supersede", judged_at: autoJudgedAt
660
+ });
661
+ } catch (error) {
662
+ logger?.warn?.(`dsh-mneme sleep: failed to record auto-resolved pair ${p.a.id}/${p.b.id}: ${String(error)}`);
663
+ }
664
+ continue;
665
+ }
666
+ // 自动档没落地 → 退回人工队列。绝不因为「本来想自动处理」就把这一对丢掉,
667
+ // 那才是真正的静默丢失。两种没落地的原因都要说清楚:
668
+ // · CAS 冲突(applyDecisions 记在 conflicts 里)——两侧在快照之后被改过,
669
+ // 自动判的赢家可能已经不是该保的那条,人看一眼最稳;
670
+ // · 其余异常(记在 failures 里)——写库失败之类,重试无意义就交给人。
671
+ autoFailed++;
672
+ logger?.warn?.(
673
+ `dsh-mneme sleep: auto-resolve skipped ${p.a.id}/${p.b.id}`
674
+ + (conflicts.length ? ` (concurrent edit)` : "")
675
+ + `, falling back to manual review`
676
+ );
677
+ parkPool.push(p);
678
+ }
408
679
  let frozen = 0;
409
- for (const p of selected) {
410
- if (parkPair(p, isCrossScopePair(p) ? scopeReason(p) : simReason(p))) frozen++;
680
+ let parkFailed = 0;
681
+ for (const p of parkPool) {
682
+ const outcome = parkPair(p, isCrossScopePair(p) ? scopeReason(p) : simReason(p));
683
+ if (outcome === "fresh") frozen++;
684
+ else if (outcome === "failed") parkFailed++;
411
685
  }
412
- return { status: frozen > 0 ? "ok" : "noop", frozen, scopeCandidates: crossPairs.length, pairs: selected.length };
686
+ if (pendingSkipped > 0) {
687
+ logger?.warn?.(`dsh-mneme sleep: conflict freeze queue full (${pendingNow}/${maxPending}), skipped ${pendingSkipped} conflict(s)`);
688
+ }
689
+ return {
690
+ status: frozen > 0 || autoResolved > 0 ? "ok" : "noop",
691
+ frozen,
692
+ // 自动裁决掉的不进队列,但确实是这一轮干的活——分开报,别混进 frozen
693
+ // (frozen 的语义是「给人工队列新增了几行」,混进去面板就会虚高)。
694
+ // Issue #616:这两个字段目前**没有消费者**。dream.js 的两处 return 只透出
695
+ // frozen,面板也不读它们;conflict_reviewed 台账同样没有 API 暴露,且 schema
696
+ // 不记赢家(memory_a/b 是排序过的)。也就是说自动裁决干了什么,事后只能翻
697
+ // loser 正文的「(已被取代:…)」后缀反推。补可见性之前,别指望从报告里看出
698
+ // 这一档跑了几对。
699
+ autoResolved,
700
+ autoFailed,
701
+ // 写库抛错的对既没入队也不算落地:clean=false,别让调度器当「追完了」。
702
+ clean: parkFailed === 0,
703
+ scopeCandidates: crossPairs.length,
704
+ pairs: selected.length,
705
+ batches: 0,
706
+ ledgerSkipped,
707
+ perRunSkipped,
708
+ pendingSkipped,
709
+ // #641 shadow:分诊分布随相位结果透出(dream phases 与审计行都能看到),
710
+ // 面板消费在 beta 第二批接。
711
+ ...(ledgerTriage ? { ledgerTriage } : {})
712
+ };
413
713
  }
414
714
 
415
715
  // 非 freeze:跨 scope 对先停车(绝不进 LLM 裁决),同 scope 对照常走 LLM。
416
716
  let scopeParked = 0;
417
717
  for (const p of crossPairs) {
418
- if (parkPair(p, scopeReason(p))) scopeParked++;
718
+ if (parkPair(p, scopeReason(p)) === "fresh") scopeParked++;
419
719
  }
420
720
  if (samePairs.length === 0) {
421
721
  // 没有可送 LLM 的同 scope 对:可能一对都没检测到,也可能全被台账挡下了
@@ -426,13 +726,14 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
426
726
  scopeCandidates: scopeParked,
427
727
  pairs: selected.length,
428
728
  batches: 0,
429
- ledgerSkipped
729
+ ledgerSkipped,
730
+ ...(ledgerTriage ? { ledgerTriage } : {})
430
731
  };
431
732
  }
432
733
 
433
734
  // LLM adjudication(仅同 scope 对)。
434
735
  const route = resolveSleepRoute(ctx, config, logger);
435
- if (!route) return { status: "skipped", reason: "no llm route", scopeCandidates: scopeParked, pairs: selected.length, batches: 0, ledgerSkipped };
736
+ if (!route) return { status: "skipped", reason: "no llm route", scopeCandidates: scopeParked, pairs: selected.length, batches: 0, ledgerSkipped, ...(ledgerTriage ? { ledgerTriage } : {}) };
436
737
  // 每轮总上限:分批解决的是单请求撞墙,不是"一个 449 对的池子该在一轮里跑完"。
437
738
  // 没进这一轮的对留在下一轮(它们既没被丢弃,也不在台账里)。
438
739
  const perRunCap = config.sleepConflictMaxPairsPerRun ?? 60;
@@ -443,6 +744,12 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
443
744
  // actionSet 已在候选构造前解析(跨类型候选需要它),这里只按档位选 prompt:
444
745
  // 默认档保持只有 conflict/keep 的窄 prompt,"full" 才开放六分支。
445
746
  const conflictPrompt = actionSet === "full" ? STR.prompts.conflictFull[language] : STR.prompts.conflict[language];
747
+ // reflectionUpdateEnabled=false:两份 conflict prompt 都教了模型怎么用 update
748
+ // (「信息部分过时 → update」那一支),只靠校验器拒会把每轮这份输出白付掉。
749
+ // 后缀与 dream 主链路共用同一条文案,两个入口对用户是同一个开关、同一种说法。
750
+ const conflictPromptGated = config.reflectionUpdateEnabled !== false
751
+ ? conflictPrompt
752
+ : conflictPrompt + STR.prompts.noUpdateSuffix[language];
446
753
  const runConflict = (batch, withEffort, tokens) => {
447
754
  conflictStreamFailure = "";
448
755
  const listText = batch.map((p) => STR.candidateConflicts[language](p)).join("\n\n");
@@ -470,7 +777,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
470
777
  maxTokens: tokens,
471
778
  ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
472
779
  messages: [
473
- { role: "system", content: [{ type: "text", text: conflictPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
780
+ { role: "system", content: [{ type: "text", text: conflictPromptGated }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
474
781
  { role: "user", content: [{ type: "text", text: fenceListBody(language, listText) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
475
782
  ]
476
783
  }, reportUsage, (reason) => { conflictStreamFailure = describeStreamFailure(reason); }));
@@ -534,6 +841,8 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
534
841
  // Issue #536 review(CodeRabbit ②):reflectionUpdateMaxPerRun 是**整轮**上限,
535
842
  // 分批后不能每批都拿整份额度——那样 N 批就是 N 倍上限。这里用的是本轮的剩余额度。
536
843
  maxUpdatePerRun: updateBudget,
844
+ // reflectionUpdateEnabled=false:update 单条跳过(同 dream 主链路口径)。
845
+ allowUpdate: config.reflectionUpdateEnabled !== false,
537
846
  minAgeHours: config.reflectionUpdateMinAgeHours,
538
847
  skipInvalid: config.dreamSkipInvalid !== false,
539
848
  allowCrossTypeMerge: config.allowCrossTypeMerge === true,
@@ -724,7 +1033,10 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
724
1033
  conflicts,
725
1034
  // Issue #126:#104 同向——被跳过的逐对明细要进审计行,否则事后无法判断
726
1035
  // 是哪一对、因何被跳过(此前只进 logger.warn,splice 后就地消失)。
727
- skipped
1036
+ skipped,
1037
+ // #641 shadow:分诊分布随相位结果透出(四个出口都要带——freeze/空池/无路由/
1038
+ // LLM 全路径,缺一个出口 shadow 期就有部分轮次查不到分布)。
1039
+ ...(ledgerTriage ? { ledgerTriage } : {})
728
1040
  };
729
1041
  }
730
1042
 
@@ -740,6 +1052,9 @@ function phaseDemotion(service, config, logger, runId, signal = null) {
740
1052
  const compressCut = Date.now() - compressDays * 86400000;
741
1053
  const demoted = [];
742
1054
  const archived = [];
1055
+ // 被判定为「该降级」但 demoteToSummary 实际没动手的条数。单独报出来:
1056
+ // 它正是「台账说降级了 N 条、实际一条没变」那个缺口,藏起来就等于没修。
1057
+ let demoteSkipped = 0;
743
1058
  // 政策轴在这里尤其要紧:下面两行会真改归档面(setArchived / demoteToSummary)。
744
1059
  // 把过滤放在循环之外、而不是在循环体里 continue:一旦将来有人在循环体里加
745
1060
  // 副作用(现已有),漏掉那条 continue 就是静默改到被按下的行。
@@ -749,6 +1064,10 @@ function phaseDemotion(service, config, logger, runId, signal = null) {
749
1064
  for (const m of withoutPaused(service, service.all().filter((m) => !isImportedRow(m)))) {
750
1065
  if (signal?.aborted) break;
751
1066
  if (m.archived || m.forgotten) continue;
1067
+ // 已降级的行不必再进判定链:demoteToSummary 见到 _full_content 会直接返回,
1068
+ // 但这行每轮仍会被选中、空转一次事务,再被下面无条件 push 进 demoted。
1069
+ // 提前挡掉既省事务,也让「这一轮到底动了什么」从源头算得准(#603 台账)。
1070
+ if (m._full_content) continue;
752
1071
  const ref = m.last_accessed_at ?? m.created_at;
753
1072
  if (!ref) continue;
754
1073
  const t = new Date(ref).getTime();
@@ -771,14 +1090,20 @@ function phaseDemotion(service, config, logger, runId, signal = null) {
771
1090
  } else if (t < archiveCut) {
772
1091
  // minRefTimeMs re-checks freshness inside demoteToSummary's transaction:
773
1092
  // 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);
1093
+ //
1094
+ // 返回值必须看:demoteToSummary 在三种情况下返回 undefined(行不存在、
1095
+ // 已降级、快照后被 recall 摸热),而这里原来无条件 push,于是台账把没发生
1096
+ // 的降级记成发生了——live 库实测 12 轮里虚报 43%–99%(#603)。
1097
+ const updated = service.demoteToSummary(m.id, makeSummary(m), { minRefTimeMs: archiveCut });
1098
+ if (updated) demoted.push(m.id);
1099
+ else demoteSkipped++;
776
1100
  }
777
1101
  }
778
1102
  return {
779
1103
  status: demoted.length || archived.length ? "ok" : "noop",
780
1104
  demoted,
781
- archived
1105
+ archived,
1106
+ demoteSkipped
782
1107
  };
783
1108
  }
784
1109
 
@@ -883,18 +1208,402 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
883
1208
  // applyDecisions 的 @param config。
884
1209
  // #512:runId 也必须给——这是 sleep 唯一的真实铸造路径,漏了它,铸造出来的
885
1210
  // pattern 就只能追到「sleep 这条管道」,追不到「哪一轮 sleep」(正是 #512 要补的口子)。
886
- const { applied, failures, conflicts } = applyDecisions(
1211
+ const { applied, merged, failures, conflicts } = applyDecisions(
887
1212
  decisions, service, logger, snapshot, { ...config, mintSource: "sleep", runId }
888
1213
  );
889
1214
  return {
890
- status: applied > 0 ? "ok" : "noop",
1215
+ // A pattern that hit an existing same-title row is refreshed in place, not
1216
+ // minted. Counting it as `ok` with `applied: 0` claims a mint that did not
1217
+ // happen; report the refresh separately (#619 audit round 6).
1218
+ status: applied > 0 ? "ok" : (merged > 0 ? "refreshed" : "noop"),
891
1219
  scanned: memories.length,
892
1220
  applied,
1221
+ merged,
893
1222
  failures,
894
1223
  conflicts
895
1224
  };
896
1225
  }
897
1226
 
1227
+ /**
1228
+ * Phase 5 — affective attribution (issue #619). The LLM reads recent memories and
1229
+ * names emotions, but the shape of what it may write is fixed here, not by the
1230
+ * prompt: an emotion tied to **one occasion** becomes an `affective_context`
1231
+ * relation carrying the attribution on the edge; only an emotion that recurs
1232
+ * across sleepAffectiveTraitMinSessions **independent sessions** may be minted as
1233
+ * a standing trait (a type=pattern memory, through the same
1234
+ * validateDecisions/applyDecisions channel every other mint uses).
1235
+ *
1236
+ * The whole point is the guardrail, so it lives in code and not in the prompt:
1237
+ * - the model may *claim* trait, but the claim only survives if provenance shows
1238
+ * enough distinct sessions. A single session is downgraded to occasion, never
1239
+ * dropped silently — dropped claims are counted and reported.
1240
+ * - person/target must resolve to entities that already exist; an emotion about
1241
+ * an unknown name is not evidence, it is a hallucination.
1242
+ * - evidence ids must be inside the scanned pool (same rule phasePatterns uses).
1243
+ * Off by default (sleepAffectiveEnabled) and a child of sleepModeEnabled: no
1244
+ * route, no entities, or the flag off all mean "skip", never "run anyway".
1245
+ */
1246
+ async function phaseAffective(ctx, service, config, logger, runId, signal = null) {
1247
+ if (config.sleepAffectiveEnabled !== true) return { status: "skipped", reason: "disabled" };
1248
+ const route = resolveSleepRoute(ctx, config, logger);
1249
+ if (!route) return { status: "skipped", reason: "no llm route" };
1250
+ const entities = service.listEntities({ limit: 1000 }) ?? [];
1251
+ if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
1252
+ const limit = config.sleepPatternMinMemories ?? 100;
1253
+ const poolQuery = {
1254
+ limit: Math.max(200, limit),
1255
+ includeForgotten: false,
1256
+ excludeTypes: ["document", "summary", "pattern"],
1257
+ excludeImported: true
1258
+ };
1259
+ const gather = (query) => withoutPaused(service, service.list(query));
1260
+ let pool = gather(poolQuery);
1261
+ if (pool.length < limit) pool = gather({ ...poolQuery, limit: null });
1262
+ const memories = [...pool].sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at))).slice(0, limit);
1263
+ if (memories.length === 0) return { status: "skipped", reason: "no memories" };
1264
+ const scannedIds = new Set(memories.map((m) => m.id));
1265
+ const byName = new Map();
1266
+ for (const e of entities) byName.set(e.name.toLowerCase(), e);
1267
+ const traitMinSessions = config.sleepAffectiveTraitMinSessions ?? 3;
1268
+ const maxRelations = config.sleepAffectiveMaxRelationsPerRun ?? 10;
1269
+ const maxTraits = config.sleepMaxPatternPerRun ?? 3;
1270
+ const language = langOf(config).startsWith("zh") ? "zh" : "en";
1271
+ const entityNames = entities.map((e) => e.name).slice(0, 200).join(", ");
1272
+ const listText = memories.map((m) => `id=${m.id} | time=${m.updated_at ?? ""} | type=${m.type} | title=${m.title} | content=${m.content}`).join("\n");
1273
+ let streamFailure;
1274
+ const runAffective = (tokens) => auditedSleepLlm(
1275
+ ctx, service, config, logger,
1276
+ {
1277
+ triggerSource: "sleep",
1278
+ operationType: "sleep_affective",
1279
+ modelId: `${route.provider}:${route.model}`,
1280
+ relatedMemoryIds: memories.map((m) => m.id),
1281
+ streamError: () => streamFailure,
1282
+ auditError: (out) => (parseJsonArray(out) ? null : "no json array in llm output"),
1283
+ metadataFrom: (out) => (parseJsonArray(out) ? null : parseFailureMeta(out))
1284
+ },
1285
+ (reportUsage) => streamText(ctx, {
1286
+ provider: route.provider,
1287
+ model: route.model,
1288
+ purpose: "sleep-affective",
1289
+ maxTokens: tokens,
1290
+ messages: [
1291
+ {
1292
+ role: "system",
1293
+ content: [{ type: "text", text: STR.prompts.affective[language].replaceAll("{{MIN_SESSIONS}}", String(traitMinSessions)) }],
1294
+ source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" }
1295
+ },
1296
+ { role: "user", content: [{ type: "text", text: fenceListBody(language, `${entityNames}\n---\n${listText}`) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
1297
+ ]
1298
+ }, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); })
1299
+ );
1300
+ const text = await withMaxTokensFallback(ctx, config.sleepMaxTokens ?? 2048, runAffective, () => streamFailure);
1301
+ if (text === undefined) {
1302
+ // #619 可见化 A:失败原因不能只写成 "llm failed"。conflict 相位
1303
+ // (src/dream/sleep.js 的 adjudicateBatch 调用处)早就把 stream 层的原文写进
1304
+ // warn 并挂在返回值的 `failure` 上,理由写在它旁边的注释里:只写 "llm failed"
1305
+ // 的话,运维分不清是上下文超限、模型拒收还是用户活动打断。affective 相位漏了
1306
+ // 这一步,实测 2026-11-10T16:09 那一轮审计行只有 {"status":"failed",
1307
+ // "error":"llm failed"},502 原文只能去 llm_audit_logs 里捞。
1308
+ // 相位自身不重试 5xx(withMaxTokensFallback 只处理 max_tokens 被拒、
1309
+ // withEffortFallback 只处理 effort 被拒),所以这里记的就是一次性失败的真因。
1310
+ if (streamFailure) logger?.warn?.(`dsh-mneme sleep: affective stream aborted or errored (${streamFailure})`);
1311
+ return { status: "failed", error: "llm failed", failure: streamFailure || undefined };
1312
+ }
1313
+ const items = parseJsonArray(text) ?? [];
1314
+ if (items.length === 0) return { status: "skipped", reason: "no attributions found" };
1315
+ const relations = [];
1316
+ const merges = [];
1317
+ const traits = [];
1318
+ const dropped = [];
1319
+ let downgradedCount = 0;
1320
+ const sessionCache = new Map();
1321
+ const sessionsFor = (memoryIds) => {
1322
+ const out = new Set();
1323
+ for (const id of memoryIds) {
1324
+ if (!sessionCache.has(id)) {
1325
+ let rows = [];
1326
+ try {
1327
+ rows = service.provenanceFor?.(id) ?? [];
1328
+ } catch {
1329
+ rows = [];
1330
+ }
1331
+ sessionCache.set(id, rows.map((r) => r.session_id).filter((s) => typeof s === "string" && s));
1332
+ }
1333
+ for (const s of sessionCache.get(id)) out.add(s);
1334
+ }
1335
+ return out;
1336
+ };
1337
+ for (const item of items) {
1338
+ if (signal?.aborted) break;
1339
+ if (!item || typeof item !== "object") continue;
1340
+ const person = byName.get(String(item.person ?? "").toLowerCase());
1341
+ const target = byName.get(String(item.target ?? "").toLowerCase());
1342
+ if (!person || !target || person.id === target.id) {
1343
+ dropped.push({ reason: "unknown entity", person: item.person, target: item.target });
1344
+ continue;
1345
+ }
1346
+ const evidence = (Array.isArray(item.evidence) ? item.evidence : []).filter((id) => scannedIds.has(id));
1347
+ if (evidence.length === 0) {
1348
+ dropped.push({ reason: "no real evidence", person: item.person, target: item.target });
1349
+ continue;
1350
+ }
1351
+ const emotion = String(item.emotion ?? "").trim();
1352
+ if (!emotion) {
1353
+ dropped.push({ reason: "no emotion", person: item.person, target: item.target });
1354
+ continue;
1355
+ }
1356
+ // The guardrail: a claimed trait is checked against provenance, and a claim
1357
+ // that does not survive is demoted — not discarded. Demoted claims are the
1358
+ // interesting number: they are exactly the mis-attributions the probe hunts.
1359
+ const claimedTrait = String(item.attribution ?? "").toLowerCase() === "trait";
1360
+ const sessions = sessionsFor(evidence);
1361
+ const downgraded = claimedTrait && sessions.size < traitMinSessions;
1362
+ const attribution = claimedTrait && !downgraded ? "trait" : "occasion";
1363
+ const record = {
1364
+ person: person.name,
1365
+ target: target.name,
1366
+ emotion,
1367
+ attribution,
1368
+ evidence,
1369
+ sessionCount: sessions.size,
1370
+ why: String(item.why ?? "").slice(0, 200),
1371
+ ...(downgraded ? { downgradedFrom: "trait" } : {})
1372
+ };
1373
+ if (downgraded) downgradedCount += 1;
1374
+ if (attribution === "trait") {
1375
+ traits.push(record);
1376
+ continue;
1377
+ }
1378
+ // #619 / Ephor: entity_relations 没有 UNIQUE 约束,saveRelation 每次
1379
+ // randomUUID() 新插一条。同一个 (person, target, 场合) 跑第二轮就会得到
1380
+ // 第二条一模一样的边——而探针会拿同一批段反复回放,重复边会让 sessionCount
1381
+ // 看起来比真实场合数多,主指标直接失真。
1382
+ //
1383
+ // 「同一场合」不能拿 evidence 有没有交集来判。两次判据都基于证据列表,
1384
+ // 而证据列表既不是场合本身、也不稳定:
1385
+ // ① 模型给 evidence 的顺序不定(parseJsonArray 也不排序),[m1,m2] 与
1386
+ // [m2,m1] 是同一场合的两种写法;
1387
+ // ② 同一会话里的两条不同记忆各说一句「他很焦虑」,证据天然不相交,可
1388
+ // 它们是同一个场合——那时该合并成一条边,而不是各写一条把边数抬高;
1389
+ // ③ 反过来,第一轮看到 1 条证据、第二轮攒到 2 条(含新会话),若判成重复
1390
+ // 就整条丢掉,边上 sessionCount 永远停在第一次那个数,将来读归因覆盖率
1391
+ // 会一直读到 1。
1392
+ // 所以键落在会话层:同一 (person, target, emotion) 且两边的会话集有交集,
1393
+ // 就是同一场合。命中时把 evidence 并集写回、按并集重算 sessionCount,而不是
1394
+ // 丢弃——被扩充的边记进 merged,让「这一轮其实补了证据」在返回值里看得见。
1395
+ // 这一步在 cap 之前:合并既有边不新增行,不该占名额。
1396
+ const currentSessions = sessions;
1397
+ const priorEdges = (service.getRelations(person.id) ?? []).filter((r) =>
1398
+ r.relation_type === "affective_context" && r.to_entity === target.id
1399
+ );
1400
+ let mergedInto = null;
1401
+ for (const edge of priorEdges) {
1402
+ const meta = edge.metadata && typeof edge.metadata === "object" ? edge.metadata : null;
1403
+ const priorEmotion = String(meta?.emotion ?? "").trim();
1404
+ if (priorEmotion !== emotion) continue;
1405
+ const priorEvidence = Array.isArray(meta?.evidence)
1406
+ ? meta.evidence
1407
+ : (edge.memory_id ? [edge.memory_id] : []);
1408
+ const priorSessions = sessionsFor(priorEvidence);
1409
+ // "Same occasion" is judged on session keys first. Only when either side
1410
+ // has no session key at all does it fall back to an evidence intersection:
1411
+ // memory_save writes memories without provenance (1671 of 8517 active rows
1412
+ // in the live database have none), so sessionsFor returns an empty set and
1413
+ // a session-only test would write the same edge again on every sleep pass,
1414
+ // inflating sessionCount past the real number of occasions.
1415
+ const sameOccasion = [...priorSessions].some((s) => currentSessions.has(s))
1416
+ || ((priorSessions.size === 0 || currentSessions.size === 0)
1417
+ && priorEvidence.some((id) => evidence.includes(id)));
1418
+ if (!sameOccasion) continue;
1419
+ mergedInto = { edge, priorEvidence, priorSessions };
1420
+ break;
1421
+ }
1422
+ if (mergedInto) {
1423
+ const { edge, priorEvidence, priorSessions } = mergedInto;
1424
+ const unionEvidence = [...new Set([...priorEvidence, ...evidence])];
1425
+ const unionSessions = new Set([...priorSessions, ...currentSessions]);
1426
+ const mergedMetadata = {
1427
+ ...(edge.metadata && typeof edge.metadata === "object" ? edge.metadata : {}),
1428
+ emotion,
1429
+ // 保留首个 attribution,但降级痕迹一旦有就留着——合并不能把
1430
+ // 「它曾被判成 trait 又被降级」这段历史抹掉。
1431
+ attribution: (edge.metadata && edge.metadata.attribution) || attribution,
1432
+ evidence: unionEvidence,
1433
+ sessionCount: unionSessions.size,
1434
+ lastMergedRunId: runId
1435
+ };
1436
+ if (record.downgradedFrom && !mergedMetadata.downgradedFrom) {
1437
+ mergedMetadata.downgradedFrom = record.downgradedFrom;
1438
+ }
1439
+ try {
1440
+ service.updateRelationMetadata?.(edge.id, mergedMetadata);
1441
+ merges.push({
1442
+ id: edge.id,
1443
+ from: person.name,
1444
+ to: target.name,
1445
+ emotion,
1446
+ evidence: unionEvidence.length,
1447
+ sessionCount: unionSessions.size
1448
+ });
1449
+ } catch (error) {
1450
+ logger?.warn?.(`dsh-mneme sleep: affective relation merge ${person.name}/${target.name} failed: ${String(error)}`);
1451
+ }
1452
+ continue;
1453
+ }
1454
+ if (relations.length >= maxRelations) {
1455
+ dropped.push({ reason: "relation cap", person: person.name, target: target.name });
1456
+ continue;
1457
+ }
1458
+ try {
1459
+ const saved = service.saveRelation({
1460
+ from_entity: person.id,
1461
+ to_entity: target.id,
1462
+ relation_type: "affective_context",
1463
+ memory_id: evidence[0],
1464
+ source: "llm",
1465
+ metadata: {
1466
+ emotion,
1467
+ attribution,
1468
+ why: record.why,
1469
+ evidence,
1470
+ sessionCount: sessions.size,
1471
+ ...(record.downgradedFrom ? { downgradedFrom: record.downgradedFrom } : {}),
1472
+ model: `${route.provider}:${route.model}`,
1473
+ runId
1474
+ }
1475
+ });
1476
+ relations.push({ id: saved?.id, from: person.name, to: target.name, emotion, attribution, ...(record.downgradedFrom ? { downgradedFrom: record.downgradedFrom } : {}) });
1477
+ } catch (error) {
1478
+ logger?.warn?.(`dsh-mneme sleep: affective relation ${person.name}/${target.name} failed: ${String(error)}`);
1479
+ }
1480
+ }
1481
+ // Traits ride the standard decision channel so they inherit every existing cap
1482
+ // and the #512 mint ledger. No new write path is introduced for them.
1483
+ let applied = 0;
1484
+ let refreshedTraits = 0;
1485
+ let failures = [];
1486
+ let conflicts = [];
1487
+ let ok = true;
1488
+ // Candidates beyond the cap are otherwise neither minted, demoted, nor
1489
+ // recorded: they simply vanish, while `traits` still counts them and a reader
1490
+ // concludes they were handled. Record them the same way `relation cap` records
1491
+ // edges, so "everything that disappears is accounted for" holds for traits.
1492
+ for (const t of traits.slice(maxTraits)) {
1493
+ dropped.push({ reason: "trait cap", person: t.person, target: t.target });
1494
+ }
1495
+ if (traits.length > 0) {
1496
+ const decisions = traits.slice(0, maxTraits).map((t) => ({
1497
+ action: "create",
1498
+ type: "pattern",
1499
+ title: STR.affectiveTrait.title[language](t),
1500
+ content: STR.affectiveTrait.content[language](t),
1501
+ importance: 3,
1502
+ evidence: t.evidence
1503
+ }));
1504
+ const snapshot = new Map();
1505
+ const validated = validateDecisions(decisions, snapshot, { maxCreatePerRun: maxTraits, allowedActions: ["create"] });
1506
+ ok = validated.ok;
1507
+ if (ok) {
1508
+ const result = applyDecisions(decisions, service, logger, snapshot, { ...config, mintSource: "sleep", runId });
1509
+ applied = result.applied;
1510
+ failures = result.failures;
1511
+ conflicts = result.conflicts;
1512
+ // A trait whose pattern was refreshed in place (same title, same row) is
1513
+ // not a new conclusion. Keep it out of `applied` and report it as a
1514
+ // refresh, otherwise two different targets would read as two minted
1515
+ // patterns while only one row exists (#619 audit round 6).
1516
+ refreshedTraits = result.merged ?? 0;
1517
+ // Ocasion edges observed before a disposition was confirmed do not get
1518
+ // rewritten — an edge records "this occasion was observed", which stays
1519
+ // true. But the edge and the pattern would then answer "how many
1520
+ // occasions" differently (edge.sessionCount vs pattern evidence), and a
1521
+ // coverage query reading both would have no way to tell they describe the
1522
+ // same thing. Mark them instead of mutating the count: the edge still
1523
+ // means one observed occasion, and `promotedToTrait` says that occasion is
1524
+ // now part of a confirmed disposition.
1525
+ for (const trait of traits.slice(0, maxTraits)) {
1526
+ const person = byName.get(String(trait.person ?? "").toLowerCase());
1527
+ const target = byName.get(String(trait.target ?? "").toLowerCase());
1528
+ if (!person || !target || person.id === target.id) continue;
1529
+ for (const edge of service.getRelations(person.id) ?? []) {
1530
+ if (edge.relation_type !== "affective_context" || edge.to_entity !== target.id) continue;
1531
+ const meta = edge.metadata && typeof edge.metadata === "object" ? edge.metadata : null;
1532
+ if (String(meta?.emotion ?? "").trim() !== trait.emotion) continue;
1533
+ if (meta?.promotedToTrait === true) continue;
1534
+ try {
1535
+ service.updateRelationMetadata?.(edge.id, {
1536
+ ...meta,
1537
+ promotedToTrait: true,
1538
+ traitSessionCount: trait.sessionCount
1539
+ });
1540
+ } catch (error) {
1541
+ logger?.warn?.(`dsh-mneme sleep: marking occasion ${edge.id} as promoted failed: ${String(error)}`);
1542
+ }
1543
+ }
1544
+ }
1545
+ }
1546
+ }
1547
+ // Aborting mid-pass is not "did nothing" and not "all done". Even when nothing
1548
+ // was written yet, "noop" would claim the whole pool was examined and found
1549
+ // empty — the loop broke at an arbitrary index, so it examined an unknown
1550
+ // prefix. Report it as aborted either way.
1551
+ if (signal?.aborted) {
1552
+ return {
1553
+ status: "aborted",
1554
+ reason: "user activity",
1555
+ scanned: memories.length,
1556
+ relations: relations.length,
1557
+ edgeWrites: relations.length,
1558
+ merged: merges.length,
1559
+ mergedList: merges,
1560
+ applied,
1561
+ refreshedTraits,
1562
+ traits: traits.length,
1563
+ downgraded: downgradedCount,
1564
+ dropped
1565
+ };
1566
+ }
1567
+ if (!ok) {
1568
+ return {
1569
+ status: "failed",
1570
+ error: "trait decisions rejected by validation",
1571
+ scanned: memories.length,
1572
+ relations: relations.length,
1573
+ edgeWrites: relations.length,
1574
+ merged: merges.length,
1575
+ mergedList: merges,
1576
+ applied: 0,
1577
+ refreshedTraits,
1578
+ traits: traits.length,
1579
+ downgraded: downgradedCount,
1580
+ dropped
1581
+ };
1582
+ }
1583
+ return {
1584
+ // "refreshed" is a real write that minted nothing: the trait pattern already
1585
+ // existed under the same title and was rewritten in place. Calling that
1586
+ // "noop" would say the phase examined the pool and found nothing to do.
1587
+ status: relations.length + merges.length + applied > 0 ? "ok" : (refreshedTraits > 0 ? "refreshed" : "noop"),
1588
+ scanned: memories.length,
1589
+ relations: relations.length,
1590
+ merged: merges.length,
1591
+ // #619: edges are real writes but not "applied decisions", so the receipt
1592
+ // would read `:0` for a run that did write. Count them separately instead
1593
+ // of folding them into `applied` (that would make the two numbers mean
1594
+ // different things depending on which phase produced them).
1595
+ edgeWrites: relations.length,
1596
+ mergedList: merges,
1597
+ applied,
1598
+ refreshedTraits,
1599
+ failures,
1600
+ conflicts,
1601
+ traits: traits.length,
1602
+ downgraded: downgradedCount,
1603
+ dropped
1604
+ };
1605
+ }
1606
+
898
1607
  /**
899
1608
  * Phase 4 — entity relation completion. Detects orphan entities (zero
900
1609
  * relations) and completes implied relations from memory co-occurrence:
@@ -958,7 +1667,9 @@ function deriveStatus(phases) {
958
1667
  const anyError = list.some((p) => p.status === "failed" || p.status === "error");
959
1668
  // Issue #126 review(Copilot):phase 自身可以是 degraded(合法子集已应用但有条目被
960
1669
  // 跳过),它同样算"有产出"——否则整轮会被误判成 noop,把真实变更报成空轮。
961
- const anyWork = list.some((p) => p.status === "ok" || p.status === "degraded");
1670
+ // #619:`refreshed` 也是一次真实写入(同题行原地刷新),只是没铸出新条目。把它
1671
+ // 排除在 anyWork 之外,整轮会被记成 noop,审计行又变回「跑了、什么都没干」。
1672
+ const anyWork = list.some((p) => p.status === "ok" || p.status === "degraded" || p.status === "refreshed");
962
1673
  const anyDegraded = list.some((p) => p.status === "degraded");
963
1674
  // Issue #529:被打断(用户恢复活动)既不是失败也不是"没活干"。相位层已经用
964
1675
  // aborted 区分了这两种,整轮层若回落到 noop,审计行就又把"没跑完"记成"跑过、
@@ -997,10 +1708,40 @@ export async function runSleep(ctx, service, config, logger, semantic = null, si
997
1708
  await attempt("demotion", () => phaseDemotion(service, config, logger, runId, signal));
998
1709
  await attempt("patterns", () => phasePatterns(ctx, service, config, logger, runId, signal));
999
1710
  await attempt("relations", () => phaseRelations(service, config, logger, runId, signal));
1711
+ // #619:情感归因排在 relations 之后——它要读实体与 provenance,先让孤立实体的
1712
+ // 结构边补完,再往上挂情绪轴;两相写的是不同 relation_type,不抢同一批对。
1713
+ await attempt("affective", () => phaseAffective(ctx, service, config, logger, runId, signal));
1000
1714
 
1001
1715
  const status = deriveStatus(phases);
1002
1716
  const route = resolveSleepRoute(ctx, config, logger);
1003
- const totalApplied = Object.values(phases).reduce((n, p) => n + (Number.isInteger(p?.applied) ? p.applied : 0), 0);
1717
+ // #619: a phase may write rows that are not "applied decisions" — the
1718
+ // affective phase inserts relation edges directly — and it may also extend an
1719
+ // existing edge instead of inserting one. Both are store writes, and the
1720
+ // receipt has to answer "did this run change anything", so both count. A run
1721
+ // whose only effect was extending an edge would otherwise read `:0`, i.e.
1722
+ // "ran and did nothing", which is exactly what a probe must never see.
1723
+ //
1724
+ // The per-kind numbers stay apart in `phases` (`applied` / `edgeWrites` /
1725
+ // `merged` / `refreshedTraits`); the receipt is a single write count on
1726
+ // purpose, because buildReceipt/parseReceipt are an eight-field contract
1727
+ // shared with organize.js and four test files. Detail lives in
1728
+ // dream_runs.phases JSON, not in the receipt.
1729
+ //
1730
+ // #619 / Ephor 第七轮 ①:`refreshedTraits` 必须计入。它是「同一标题的 pattern
1731
+ // 被原地重写」的条数——库确实变了,但没有铸新条目,所以它既不在 `applied` 里
1732
+ // (那会把刷新谎报成铸造),也不在 `merged` 里(affective 相位的 `merged` 是
1733
+ // **边**合并数 `merges.length`,同名不同义)。漏掉它的后果实测过:第二轮
1734
+ // `phase.status=refreshed`、`refreshedTraits=1`,整轮 receipt 第 7 段仍是 0,
1735
+ // 于是「相位说改了、整轮说没改」,正是上一轮刚修掉的「改了库读成 0」形态。
1736
+ const totalApplied = Object.values(phases).reduce(
1737
+ (n, p) =>
1738
+ n +
1739
+ (Number.isInteger(p?.applied) ? p.applied : 0) +
1740
+ (Number.isInteger(p?.edgeWrites) ? p.edgeWrites : 0) +
1741
+ (Number.isInteger(p?.merged) ? p.merged : 0) +
1742
+ (Number.isInteger(p?.refreshedTraits) ? p.refreshedTraits : 0),
1743
+ 0
1744
+ );
1004
1745
  // Issue #126(与 #104 同向):把各 phase 的跳过明细汇总成一行审计数据,带 phase
1005
1746
  // 名(同一次 run 里 conflicts 与 patterns 都可能产出 skipped)。全空时写 NULL,
1006
1747
  // 不落空数组——审计行只记真实发生过的跳过。