@modusensus/dsh-mneme 0.9.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +57 -10
  2. package/bin/cli.mjs +215 -2
  3. package/docs/CONFIGURATION.md +25 -2
  4. package/docs/DAEMON.md +13 -2
  5. package/docs/DISTILL.md +150 -4
  6. package/docs/SHARE.md +552 -0
  7. package/docs/SLEEP.md +21 -7
  8. package/docs/STORAGE.md +2 -1
  9. package/lib/api-standalone.js +294 -8
  10. package/lib/api.js +217 -3
  11. package/lib/client.js +919 -39
  12. package/lib/config.js +33 -1
  13. package/lib/continuity.js +11 -3
  14. package/lib/document.js +15 -2
  15. package/lib/dream/decisions.js +176 -30
  16. package/lib/dream/sleep.js +530 -94
  17. package/lib/dream.js +174 -21
  18. package/lib/fence.js +24 -0
  19. package/lib/index.js +15 -6
  20. package/lib/inject.js +9 -4
  21. package/lib/lang.js +38 -16
  22. package/lib/memory-source.js +9 -0
  23. package/lib/organize.js +5 -1
  24. package/lib/pipeline-nudge-auto.js +3 -0
  25. package/lib/pipeline-nudge.js +41 -8
  26. package/lib/pipeline-runner.js +15 -0
  27. package/lib/pipeline.js +62 -1
  28. package/lib/pre-inject-gate.js +14 -0
  29. package/lib/profile-extract.js +4 -1
  30. package/lib/provenance-http.js +251 -0
  31. package/lib/serve.js +13 -0
  32. package/lib/service.js +97 -4
  33. package/lib/session-gate.js +91 -0
  34. package/lib/settings.js +13 -1
  35. package/lib/share-http.js +281 -0
  36. package/lib/share-import.js +234 -0
  37. package/lib/share.js +335 -0
  38. package/lib/store.js +797 -28
  39. package/lib/summarize.js +209 -23
  40. package/lib/tools.js +26 -6
  41. package/package.json +4 -2
  42. package/src/api-standalone.js +294 -8
  43. package/src/api.js +217 -3
  44. package/src/config.js +33 -1
  45. package/src/continuity.js +11 -3
  46. package/src/document.js +15 -2
  47. package/src/dream/decisions.js +176 -30
  48. package/src/dream/sleep.js +530 -94
  49. package/src/dream.js +174 -21
  50. package/src/fence.js +24 -0
  51. package/src/index.js +15 -6
  52. package/src/inject.js +9 -4
  53. package/src/lang.js +38 -16
  54. package/src/memory-source.js +9 -0
  55. package/src/organize.js +5 -1
  56. package/src/pipeline-nudge-auto.js +3 -0
  57. package/src/pipeline-nudge.js +41 -8
  58. package/src/pipeline-runner.js +15 -0
  59. package/src/pipeline.js +62 -1
  60. package/src/pre-inject-gate.js +14 -0
  61. package/src/profile-extract.js +4 -1
  62. package/src/provenance-http.js +251 -0
  63. package/src/serve.js +13 -0
  64. package/src/service.js +97 -4
  65. package/src/session-gate.js +91 -0
  66. package/src/settings.js +13 -1
  67. package/src/share-http.js +281 -0
  68. package/src/share-import.js +234 -0
  69. package/src/share.js +335 -0
  70. package/src/store.js +797 -28
  71. package/src/summarize.js +209 -23
  72. package/src/tools.js +26 -6
  73. package/test/api.test.js +523 -6
  74. package/test/audit.test.js +2 -2
  75. package/test/cli-receive.test.js +390 -0
  76. package/test/cli-share.test.js +437 -0
  77. package/test/client-share.test.js +225 -0
  78. package/test/client.test.js +354 -0
  79. package/test/config.test.js +8 -0
  80. package/test/conflict-reviewed.test.js +79 -0
  81. package/test/continuity.test.js +29 -1
  82. package/test/dedupe-candidate-window.test.js +180 -0
  83. package/test/dependency-overrides.test.js +78 -0
  84. package/test/differentiate-feedback.test.js +248 -0
  85. package/test/dream-candidate-hybrid.test.js +2 -2
  86. package/test/dream-decision-evidence.test.js +314 -0
  87. package/test/dream-fence.test.js +207 -0
  88. package/test/dream.test.js +87 -5
  89. package/test/helpers/dream-mock.js +6 -2
  90. package/test/inject-pools.test.js +2 -1
  91. package/test/llm-message-source.test.js +1 -1
  92. package/test/mint-source.test.js +84 -1
  93. package/test/pipeline-nudge-auto.test.js +26 -2
  94. package/test/pipeline-nudge.test.js +87 -0
  95. package/test/pipeline-r1r2.test.js +129 -0
  96. package/test/pipeline-runner.test.js +26 -0
  97. package/test/policy-epoch.test.js +2 -2
  98. package/test/prompt-stance.test.js +128 -0
  99. package/test/provenance-http.test.js +367 -0
  100. package/test/readme-claims.test.js +45 -0
  101. package/test/reasoning-effort.test.js +10 -10
  102. package/test/release-docs.test.js +7 -1
  103. package/test/scope-candidates.test.js +7 -4
  104. package/test/scope-state.test.js +130 -1
  105. package/test/security-doc.test.js +43 -4
  106. package/test/serve.test.js +62 -0
  107. package/test/session-gate.test.js +264 -0
  108. package/test/settings.test.js +4 -0
  109. package/test/share-foreign-rows.test.js +144 -0
  110. package/test/share-http.test.js +144 -0
  111. package/test/share-import.test.js +577 -0
  112. package/test/share-imported-guard.test.js +311 -0
  113. package/test/share-imported-identity.test.js +111 -0
  114. package/test/share-pack.test.js +511 -0
  115. package/test/share-receive-http.test.js +756 -0
  116. package/test/share-routes.test.js +337 -0
  117. package/test/share-store.test.js +133 -0
  118. package/test/share-validate.test.js +101 -0
  119. package/test/sleep-action-set.test.js +16 -11
  120. package/test/sleep-catchup.test.js +315 -0
  121. package/test/sleep-failure-backoff.test.js +156 -0
  122. package/test/sleep.test.js +293 -0
  123. package/test/standalone-api.test.js +574 -5
  124. package/test/store.test.js +220 -0
  125. package/test/summarize-parse-diagnostics.test.js +75 -0
  126. package/test/summarize.test.js +187 -2
@@ -21,8 +21,14 @@ import { randomUUID, createHash } from "node:crypto";
21
21
  import { validateDecisions, applyDecisions, ACTIONS } from "./decisions.js";
22
22
  import { findPotentialConflicts, cosineSimilarity } from "./clustering.js";
23
23
  import { scopeKeyOf } from "../scope.js";
24
- import { buildReceipt, describeStreamFailure, resolveDreamEffort, withEffortFallback, withMaxTokensFallback, maintainIndexAfterDream } from "../dream.js";
24
+ import { buildReceipt, describeStreamFailure, resolveDreamEffort, withEffortFallback, withMaxTokensFallback, maintainIndexAfterDream, fenceListBody, parseFailureMeta } from "../dream.js";
25
25
  import { computeHeat } from "../heat.js";
26
+ // 四个相位读的都是行池,而动行的写手不经汇流口:phaseDemotion 直接 setArchived /
27
+ // demoteToSummary,phasePatterns 还会 mint 出新行,所以这条契约要在每处各挡一遍
28
+ // (清单与静态锁见 test/share-imported-guard.test.js)。
29
+ import { isImportedRow } from "../memory-source.js";
30
+ // Issue #533:查账本时要拿两侧当前的正文哈希,与 applyDifferentiate 记账时的口径同源。
31
+ import { contentHashOf } from "../content-hash.js";
26
32
 
27
33
  // Issue #126 review(Copilot):默认档(sleepActionSet="conflict")允许的动作 = 全局
28
34
  // ACTIONS 去掉 #126 新增的两个。从 ACTIONS 派生而非硬编码,将来再扩动作集时自动跟随。
@@ -36,6 +42,22 @@ const SUMMARY_MAX = 120;
36
42
  // aggressive low-confidence pairs too (0.75) — bloated stores
37
43
  const CONFLICT_THRESHOLDS = { gentle: 0.92, normal: 0.85, aggressive: 0.75 };
38
44
 
45
+ // Issue #529(C 项的替代实现):连续失败时最小间隔的封顶。基数默认 8 小时,
46
+ // 2^3 就到 64 小时——不封顶的话模型持续失败会让 sleep 事实上停摆好几天,而
47
+ // 用户完全看不出来。24 小时 = 「最多一天试一次」,与 #292 给 autoDream 的
48
+ // 30 分钟封顶同一意图(封顶值不同是因为两个调度的基数差一个数量级)。
49
+ //
50
+ // 实际封顶取 max(基数, 24h),不是硬 24h:`sleepMinIntervalHours` 允许 1–168,
51
+ // 用户配 48 小时就是明说"两天跑一次",退避不该把它压回 24 小时(封顶只能放宽
52
+ // 间隔、不能收紧用户自己设的)。
53
+ const FAILURE_BACKOFF_CAP_MS = 24 * 3600000;
54
+
55
+ // Issue #529(续排):一轮内部最多连追多少轮。449 对的池子按每轮 60 对只需 8 轮,
56
+ // 24 留足余量;超过就退回正常最小间隔。防的是"池子在长、每轮都 clean 但永远追不完"
57
+ // ——用户持续写入时池子进得比追得快,光看 clean 会变成永不落地的每五分钟一轮,
58
+ // 而审计上每轮都"有产出",肉眼很难发现。
59
+ const CATCHUP_STREAK_CAP = 24;
60
+
39
61
  function parseJsonArray(text) {
40
62
  if (typeof text !== "string") return undefined;
41
63
  const start = text.indexOf("[");
@@ -102,8 +124,11 @@ async function auditedSleepLlm(ctx, service, config, logger, spec, body) {
102
124
  let outputTokens = 0;
103
125
  let status = "success";
104
126
  let errorMessage = null;
127
+ // 提到 try 之外:finally 里的 metadataFrom 要读这一轮的原始输出(dream.js 的
128
+ // runAuditedLlm 就是这么声明的)。留在 try 里,finally 看不见它,读数会静默变空。
129
+ let result;
105
130
  try {
106
- const result = await body((usage) => {
131
+ result = await body((usage) => {
107
132
  if (!usage) return;
108
133
  const i = usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
109
134
  const o = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
@@ -129,6 +154,17 @@ async function auditedSleepLlm(ctx, service, config, logger, spec, body) {
129
154
  errorMessage = String(error?.message ?? error);
130
155
  throw error;
131
156
  } finally {
157
+ // spec.metadataFrom(result):与 dream.js 的 runAuditedLlm 同一形状(这里是一份并行
158
+ // 实现,不是同一个函数——两份审计写法的字段必须一致,否则面板按 operation_type
159
+ // 筛出来的读数会时有时没有)。判据函数只读 result,不读闭包,理由同 dream.js。
160
+ let metadata;
161
+ if (result !== undefined && typeof spec.metadataFrom === "function") {
162
+ try {
163
+ metadata = spec.metadataFrom(result) ?? undefined;
164
+ } catch {
165
+ metadata = undefined;
166
+ }
167
+ }
132
168
  try {
133
169
  service.saveLlmAudit({
134
170
  timestamp,
@@ -142,7 +178,8 @@ async function auditedSleepLlm(ctx, service, config, logger, spec, body) {
142
178
  duration_ms: Date.now() - startedAt,
143
179
  status,
144
180
  error_message: errorMessage,
145
- related_memory_ids: spec.relatedMemoryIds ?? []
181
+ related_memory_ids: spec.relatedMemoryIds ?? [],
182
+ ...(metadata ? { metadata } : {})
146
183
  });
147
184
  } catch (auditError) {
148
185
  logger?.warn?.(`dsh-mneme sleep: llm audit write failed: ${String(auditError)}`);
@@ -205,7 +242,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
205
242
  }
206
243
  const strictness = config.sleepConflictStrictness ?? "normal";
207
244
  const threshold = CONFLICT_THRESHOLDS[strictness] ?? CONFLICT_THRESHOLDS.normal;
208
- const memories = withoutPaused(service, service.all().filter((m) => !m.archived && !m.forgotten && m.type !== "summary" && m.type !== "document"));
245
+ const memories = withoutPaused(service, service.all().filter((m) => !isImportedRow(m) && !m.archived && !m.forgotten && m.type !== "summary" && m.type !== "document"));
209
246
  if (memories.length < 2) return { status: "skipped", reason: "too few memories" };
210
247
  if (signal?.aborted) return { status: "aborted", reason: "user activity" };
211
248
 
@@ -262,9 +299,83 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
262
299
  // Dedupe: each memory participates in at most one pair, highest similarity
263
300
  // first — overlapping pairs would violate validateDecisions' "one claim".
264
301
  pairs.sort((a, b) => b.similarity - a.similarity);
302
+
303
+ // Issue #533:已经裁决过「互补、两条都留」的对不再重判。
304
+ //
305
+ // 过滤必须发生在 used 去重**之前**:若先占位再丢弃,一条已裁决的记忆会把
306
+ // 名额占住,而它真正的对手又是自己人,这一轮就白跑——抑制一对不该顺带压掉
307
+ // 另一对候选。判据与 applyDifferentiate 的幂等同源:账本里有这对、且两侧正文
308
+ // 的 content_hash 与记账时一致(任一侧改过 = 结论可能不再成立,重判)。
309
+ // 存量兼容走同一格:旧版本把注记写进正文却没账本行,两侧都还带着注记块也算
310
+ // 已裁决,免得升级后第一轮把所有老对重判一遍。
311
+ const alreadyDifferentiated = (p) => {
312
+ const a = service.getById(p.a.id);
313
+ const b = service.getById(p.b.id);
314
+ if (!a || !b) return false;
315
+ if (typeof service.findDifferentiatedPair === "function") {
316
+ const record = service.findDifferentiatedPair(a.id, b.id);
317
+ if (record) {
318
+ const [ha, hb] = a.id <= b.id
319
+ ? [contentHashOf(a), contentHashOf(b)]
320
+ : [contentHashOf(b), contentHashOf(a)];
321
+ // 账本行没记 hash(存量/异常写入)时不下结论,宁可多判一次。
322
+ return record.hash_a != null && record.hash_b != null
323
+ && record.hash_a === ha && record.hash_b === hb;
324
+ }
325
+ }
326
+ return STR.differentiatedNoteRe.test(String(a.content ?? ""))
327
+ && STR.differentiatedNoteRe.test(String(b.content ?? ""));
328
+ };
329
+ const fresh = [];
330
+ let suppressed = 0;
331
+ for (const p of pairs) {
332
+ if (alreadyDifferentiated(p)) {
333
+ suppressed += 1;
334
+ continue;
335
+ }
336
+ fresh.push(p);
337
+ }
338
+ if (fresh.length === 0) {
339
+ // 与 "no conflicts found" 分开报:一对都没检测到 vs 检测到但全都裁决过,
340
+ // 是完全不同的两件事,混成一句会让「sleep 是不是没在干活」读不出来。
341
+ return { status: "noop", reason: "all candidate pairs already differentiated", suppressed };
342
+ }
343
+
344
+ // Issue #529:输入侧预算。此前 samePairs 是**全部**同 scope 相似对,一次拼成一个
345
+ // prompt——实测单请求 163,054 输入 token(服务端 --ctx-size 163,840),撞墙后按冷却
346
+ // 重排,下一轮送进去的还是同一份输入,于是 23 轮连续失败。
347
+ //
348
+ // 预筛掉「已经判过且两侧都没动过」的对:判 keep 曾经是彻底的 no-op,既不留痕也不
349
+ // 影响下一轮的候选池,同一对每个冷却周期都被重新送审一遍。台账(conflict_reviewed)
350
+ // 记的是"这段输入被看过",两侧任一 updated_at 晚于 judged_at 就重新入池——与
351
+ // conflict_pending 复核项 4 同一条宽松代理:任何字段更新都算值得再看一眼。
352
+ //
353
+ // Issue #536 review(CodeRabbit ①):预筛必须发生在下面的 used 去重**之前**,理由与
354
+ // #533 那条一模一样——先占位再去掉,一条已审的记忆会把自己的配对名额占住,而它真正
355
+ // 的对手(另一条没审过的记忆)因此进不了这一轮。三条边界:
356
+ // · 只筛同 scope 对:跨 scope 对本来就要停车进人工队列,不能被台账吞掉;
357
+ // · freeze 模式不筛:那一档只停车不送 LLM,台账与它无关(ledgerSkipped 记 0);
358
+ // · ledgerSkipped 在这里一次数清,后面 candidates 直接取 samePairs,不再二次过滤。
359
+ const isCrossScopePair = (p) =>
360
+ scopeKeyOf(p.a.agent_scope) !== scopeKeyOf(p.b.agent_scope)
361
+ || scopeKeyOf(p.a.workspace_scope) !== scopeKeyOf(p.b.workspace_scope);
362
+ const ledger = typeof service.loadConflictReviewed === "function"
363
+ ? (service.loadConflictReviewed() ?? new Map())
364
+ : new Map();
365
+ const pairKey = (p) => (p.a.id < p.b.id ? `${p.a.id}\u0000${p.b.id}` : `${p.b.id}\u0000${p.a.id}`);
366
+ const alreadyJudged = (p) => {
367
+ const rec = ledger.get(pairKey(p));
368
+ if (!rec?.judged_at) return false;
369
+ return (p.a.updated_at ?? "") <= rec.judged_at && (p.b.updated_at ?? "") <= rec.judged_at;
370
+ };
371
+ const selectionPairs = config.conflictFreezeEnabled === true
372
+ ? fresh
373
+ : fresh.filter((p) => isCrossScopePair(p) || !alreadyJudged(p));
374
+ const ledgerSkipped = fresh.length - selectionPairs.length;
375
+
265
376
  const used = new Set();
266
377
  const selected = [];
267
- for (const p of pairs) {
378
+ for (const p of selectionPairs) {
268
379
  if (used.has(p.a.id) || used.has(p.b.id)) continue;
269
380
  used.add(p.a.id);
270
381
  used.add(p.b.id);
@@ -276,9 +387,6 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
276
387
  // 并存」正是收窄/提升裁决的典型候选)不能自动裁决:归档败者可能销毁该内容在
277
388
  // 某个归属下的唯一副本,LLM 也无从替用户决定归属。一律停车到冲突待确认队列
278
389
  // (专属 reason),同 scope 对维持既有路径(freeze 停车 / LLM 裁决)。
279
- const isCrossScopePair = (p) =>
280
- scopeKeyOf(p.a.agent_scope) !== scopeKeyOf(p.b.agent_scope)
281
- || scopeKeyOf(p.a.workspace_scope) !== scopeKeyOf(p.b.workspace_scope);
282
390
  const crossPairs = selected.filter(isCrossScopePair);
283
391
  const samePairs = selected.filter((p) => !isCrossScopePair(p));
284
392
  const parkPair = (p, reason) => {
@@ -310,39 +418,51 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
310
418
  if (parkPair(p, scopeReason(p))) scopeParked++;
311
419
  }
312
420
  if (samePairs.length === 0) {
313
- return { status: scopeParked > 0 ? "ok" : "noop", frozen: 0, scopeCandidates: scopeParked, pairs: selected.length };
421
+ // 没有可送 LLM 的同 scope 对:可能一对都没检测到,也可能全被台账挡下了
422
+ // (ledgerSkipped)。两种都要如实报,否则「sleep 怎么什么都不干」读不出来。
423
+ return {
424
+ status: scopeParked > 0 ? "ok" : "noop",
425
+ frozen: 0,
426
+ scopeCandidates: scopeParked,
427
+ pairs: selected.length,
428
+ batches: 0,
429
+ ledgerSkipped
430
+ };
314
431
  }
315
432
 
316
433
  // LLM adjudication(仅同 scope 对)。
317
434
  const route = resolveSleepRoute(ctx, config, logger);
318
- if (!route) return { status: "skipped", reason: "no llm route", scopeCandidates: scopeParked, pairs: selected.length };
319
- const snapshot = new Map();
320
- for (const p of samePairs) {
321
- snapshot.set(p.a.id, p.a);
322
- snapshot.set(p.b.id, p.b);
323
- }
324
- const listText = samePairs.map((p) =>
325
- STR.candidateConflicts[language](p)
326
- ).join("\n\n");
435
+ if (!route) return { status: "skipped", reason: "no llm route", scopeCandidates: scopeParked, pairs: selected.length, batches: 0, ledgerSkipped };
436
+ // 每轮总上限:分批解决的是单请求撞墙,不是"一个 449 对的池子该在一轮里跑完"。
437
+ // 没进这一轮的对留在下一轮(它们既没被丢弃,也不在台账里)。
438
+ const perRunCap = config.sleepConflictMaxPairsPerRun ?? 60;
439
+ const pool = perRunCap > 0 ? samePairs.slice(0, perRunCap) : samePairs;
440
+ const perRunSkipped = samePairs.length - pool.length;
327
441
  const sleepEffort = await resolveDreamEffort(ctx, route, config.sleepReasoningEffort, logger);
328
442
  let conflictStreamFailure = "";
329
443
  // actionSet 已在候选构造前解析(跨类型候选需要它),这里只按档位选 prompt:
330
444
  // 默认档保持只有 conflict/keep 的窄 prompt,"full" 才开放六分支。
331
445
  const conflictPrompt = actionSet === "full" ? STR.prompts.conflictFull[language] : STR.prompts.conflict[language];
332
- const runConflict = (withEffort, tokens) => {
446
+ const runConflict = (batch, withEffort, tokens) => {
333
447
  conflictStreamFailure = "";
448
+ const listText = batch.map((p) => STR.candidateConflicts[language](p)).join("\n\n");
334
449
  return auditedSleepLlm(ctx, service, config, logger, {
335
450
  triggerSource: "sleep",
336
451
  operationType: "sleep_conflict",
337
452
  modelId: `${route.provider}:${route.model}`,
338
453
  // 候选对的双方 id:面板的活动流用 related_memory_ids 解析「沉淀条数」。
339
- relatedMemoryIds: samePairs.flatMap((p) => [p.a.id, p.b.id]),
454
+ // 分批后必须取**这一批**的 id:整轮的 60 对一次写进每一批的审计行,面板会把
455
+ // 同一个「沉淀条数」重复累加五遍。
456
+ relatedMemoryIds: batch.flatMap((p) => [p.a.id, p.b.id]),
340
457
  streamError: () => conflictStreamFailure,
341
458
  // 纯判据:输出里没有 JSON 数组 ⇒ 这轮是失败的调用,审计记 error。**不要**把
342
459
  // 解析结果存进闭包给下面的控制流用——审计关闭时 auditedSleepLlm 整段早退、
343
460
  // 这个钩子不会被调用,控制流会跟着失效(dream 的既有坑)。代价是审计开启时
344
461
  // 多解析一次,换来两条路径彻底解耦。
345
- auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output")
462
+ auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output"),
463
+ // 纯判据,与 auditError 同一条约定:重解析一次、不读闭包(审计关掉时那些闭包
464
+ // 根本不会被赋值,读它会让「审计说了什么」依赖调用方走到了哪一行)。
465
+ metadataFrom: (text) => (parseJsonArray(text) ? null : parseFailureMeta(text))
346
466
  }, (reportUsage) => streamText(ctx, {
347
467
  provider: route.provider,
348
468
  model: route.model,
@@ -351,76 +471,255 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
351
471
  ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
352
472
  messages: [
353
473
  { role: "system", content: [{ type: "text", text: conflictPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
354
- { role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
474
+ { role: "user", content: [{ type: "text", text: fenceListBody(language, listText) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
355
475
  ]
356
476
  }, reportUsage, (reason) => { conflictStreamFailure = describeStreamFailure(reason); }));
357
477
  };
358
- // Issue #478:睡眠侧同样接 max_tokens 降级——面板 hint 已承诺「超上限同样自动降级」,
359
- // 只挂 dream 三个点会让 sleepMaxTokens 的 131072 上限继续原样发。
360
- const text = await withMaxTokensFallback(ctx, config.sleepMaxTokens ?? 2048, (tokens) => withEffortFallback(ctx, sleepEffort, () => runConflict(true, tokens), () => runConflict(false, tokens), () => conflictStreamFailure), () => conflictStreamFailure);
361
- if (text === undefined) {
362
- if (conflictStreamFailure) ctx.logger?.warn?.(`dsh-mneme sleep: conflict stream aborted or errored (${conflictStreamFailure})`);
363
- return { status: "failed", error: "llm failed" };
364
- }
365
- const decisions = parseJsonArray(text);
366
- if (!decisions) return { status: "failed", error: "invalid decisions json" };
367
- // validateDecisions 要求每个 snapshot id 恰好被 claim 一次。v0.4.4 起它本身
368
- // 就会为未覆盖的 id 自动补 keep(dreamImplicitKeep 默认开启),这里保留显式
369
- // 预填作为防御性双保险——漏判读作"未裁决冲突"而非"冲突阶段整体失败"。
370
- const covered = new Set();
371
- for (const d of decisions) {
372
- // Issue #126 review(Copilot):supersede 同样用 winner/loser 定位(没有 ids),
373
- // 此前只认 conflict → 它的两个目标都没进 covered → 被补成 implicit keep → 与
374
- // supersede 的 claim 冲突:严格模式整单拒绝,宽容模式把合成的 keep 记成 skipped。
375
- if (d?.action === "conflict" || d?.action === "supersede") {
376
- if (typeof d?.winner === "string") covered.add(d.winner);
377
- if (typeof d?.loser === "string") covered.add(d.loser);
378
- } else if (Array.isArray(d?.ids)) {
379
- for (const id of d.ids) covered.add(id);
478
+ // 单批的完整往返:送 → 解析 → 校验 → 应用 → 记账。抽成函数是为了让失败只影响
479
+ // 自己这一批(下面的循环),而不是把整轮判死。
480
+ const adjudicateBatch = async (batch, updateBudget) => {
481
+ // Issue #529 review(CodeRabbit ①):留痕写入失败要能被上层看见。只打 warn 的话,
482
+ // 这一对既没记账、又被算成"送出去的全落地了",于是下一轮它原样回到候选池、又被
483
+ // 算成可续排——每个静默窗口重问一次同一个冲突,直到连击上限才停,而审计上每轮
484
+ // 都是干净的成功轮。计数随返回值透出,由 phaseConflicts 折进 clean。
485
+ let ledgerFailed = 0;
486
+ // Issue #536 review(CodeRabbit ③):judged_at 是**发送时刻**,不是落地时刻。
487
+ //
488
+ // 取 max(墙钟, 本批两侧里最新的 updated_at):`updated_at` 用的是 store 的
489
+ // **单调时钟**(src/store.js 的 nowIso:同一毫秒内的连续写入逐次 +1ms),它可能
490
+ // 比墙钟快几毫秒;直接用墙钟会记出一个早于被测行自身版本的 `judged_at`,于是
491
+ // 「这一对刚判过」在下一轮的 `alreadyJudged` 里读成「判过之后又被改过」,判 keep
492
+ // 的死循环原样复活(CI 的 test-host-legacy 腿抓到过一次真 flake)。两列同源才可比。
493
+ const judgedAt = batch.reduce(
494
+ (acc, p) => (p.a.updated_at > acc ? p.a.updated_at : (p.b.updated_at > acc ? p.b.updated_at : acc)),
495
+ new Date().toISOString()
496
+ );
497
+ const text = await withMaxTokensFallback(ctx, config.sleepMaxTokens ?? 2048, (tokens) => withEffortFallback(ctx, sleepEffort, () => runConflict(batch, true, tokens), () => runConflict(batch, false, tokens), () => conflictStreamFailure), () => conflictStreamFailure);
498
+ if (text === undefined) {
499
+ if (conflictStreamFailure) ctx.logger?.warn?.(`dsh-mneme sleep: conflict stream aborted or errored (${conflictStreamFailure})`);
500
+ return { status: "failed", error: "llm failed", failure: conflictStreamFailure || undefined };
380
501
  }
381
- }
382
- for (const id of snapshot.keys()) {
383
- if (!covered.has(id)) decisions.push({ action: "keep", ids: [id] });
384
- }
385
- // Issue #126:sleep 是多对批量裁决,严格模式"一票否决"太脆(一对非法就丢掉整轮
386
- // 成果)→ 与 dream 对齐透传 skipInvalid:非法的那一对只跳过自己,合法子集照常
387
- // 应用,run 记 degraded。update 上限与保护期同样取 dream 的同一组配置,保证两个
388
- // 模块对"什么算合法"的判据一致。
389
- const { ok, errors, skipped } = validateDecisions(decisions, snapshot, {
390
- maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
391
- minAgeHours: config.reflectionUpdateMinAgeHours,
392
- skipInvalid: config.dreamSkipInvalid !== false,
393
- allowCrossTypeMerge: config.allowCrossTypeMerge === true,
394
- // issue #339 / E8:guarded 类型 merge 护栏(默认开,dreamMergeGuard),
395
- // 与 dream 主链路同一判据。
396
- mergeGuard: config.dreamMergeGuard === true,
397
- // Issue #126 review(Copilot):默认档必须真的只放行旧动作集——只换 prompt 挡不住
398
- // 模型自发输出 supersede / differentiate,那样"opt-in 零行为变化"就不成立。
399
- allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS
400
- });
401
- if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}`, skipped };
402
- // 同 phasePatterns——本相位的 create 也落 source=sleep(默认档的
403
- // LEGACY_ACTIONS 含 create,理论上可 mint)。
404
- const { applied, failures, conflicts } = applyDecisions(
405
- decisions, service, logger, snapshot, { ...config, mintSource: "sleep" }
406
- );
407
- // Issue #126 review(Copilot):事务内 service.update 会跳过 scheduleEmbed(txDepth>0),
408
- // 而 sleep 路径此前没有 dream 那样的 post-apply 索引维护——differentiate 的差异注记与
409
- // supersede 的归档都反映不到缓存向量,"注记进 embedding 防再判重复"就是空承诺。
410
- if (applied > 0 && semantic?.embedder && semantic?.vectorIndex) {
502
+ const decisions = parseJsonArray(text);
503
+ if (!decisions) return { status: "failed", error: "invalid decisions json" };
504
+ // 本批的 snapshot 只含本批的 id。validateDecisions 要求每个 snapshot id 恰好被
505
+ // claim 一次,拿整轮的 snapshot 校验单批输出会把其余 48 对判成"未裁决"。
506
+ const batchSnapshot = new Map();
507
+ for (const p of batch) {
508
+ batchSnapshot.set(p.a.id, p.a);
509
+ batchSnapshot.set(p.b.id, p.b);
510
+ }
511
+ // v0.4.4 起 validateDecisions 本身就会为未覆盖的 id 自动补 keep(dreamImplicitKeep
512
+ // 默认开启),这里保留显式预填作为防御性双保险——漏判读作"未裁决冲突"而非
513
+ // "冲突阶段整体失败"。
514
+ const covered = new Set();
515
+ for (const d of decisions) {
516
+ // Issue #126 review(Copilot):supersede 同样用 winner/loser 定位(没有 ids),
517
+ // 此前只认 conflict → 它的两个目标都没进 covered → 被补成 implicit keep → 与
518
+ // supersede 的 claim 冲突:严格模式整单拒绝,宽容模式把合成的 keep 记成 skipped。
519
+ if (d?.action === "conflict" || d?.action === "supersede") {
520
+ if (typeof d?.winner === "string") covered.add(d.winner);
521
+ if (typeof d?.loser === "string") covered.add(d.loser);
522
+ } else if (Array.isArray(d?.ids)) {
523
+ for (const id of d.ids) covered.add(id);
524
+ }
525
+ }
526
+ for (const id of batchSnapshot.keys()) {
527
+ if (!covered.has(id)) decisions.push({ action: "keep", ids: [id] });
528
+ }
529
+ // Issue #126:sleep 是多对批量裁决,严格模式"一票否决"太脆(一对非法就丢掉整轮
530
+ // 成果)→ 与 dream 对齐透传 skipInvalid:非法的那一对只跳过自己,合法子集照常
531
+ // 应用,run 记 degraded。update 上限与保护期同样取 dream 的同一组配置,保证两个
532
+ // 模块对"什么算合法"的判据一致。
533
+ const { ok, errors, skipped } = validateDecisions(decisions, batchSnapshot, {
534
+ // Issue #536 review(CodeRabbit ②):reflectionUpdateMaxPerRun 是**整轮**上限,
535
+ // 分批后不能每批都拿整份额度——那样 N 批就是 N 倍上限。这里用的是本轮的剩余额度。
536
+ maxUpdatePerRun: updateBudget,
537
+ minAgeHours: config.reflectionUpdateMinAgeHours,
538
+ skipInvalid: config.dreamSkipInvalid !== false,
539
+ allowCrossTypeMerge: config.allowCrossTypeMerge === true,
540
+ // issue #339 / E8:guarded 类型 merge 护栏(默认开,dreamMergeGuard),
541
+ // 与 dream 主链路同一判据。
542
+ mergeGuard: config.dreamMergeGuard === true,
543
+ // Issue #126 review(Copilot):默认档必须真的只放行旧动作集——只换 prompt 挡不住
544
+ // 模型自发输出 supersede / differentiate,那样"opt-in 零行为变化"就不成立。
545
+ allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS
546
+ });
547
+ if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}`, skipped };
548
+ // 同 phasePatterns——本相位的 create 也落 source=sleep(默认档的
549
+ // LEGACY_ACTIONS 含 create,理论上可 mint)。
550
+ const { applied, failures, conflicts } = applyDecisions(
551
+ // runId 让 differentiate 的账本行能追到是哪一轮 sleep 判的(issue #533)。
552
+ decisions, service, logger, batchSnapshot, { ...config, mintSource: "sleep", runId }
553
+ );
554
+ // Issue #536 review(CodeRabbit ②):本轮剩余的 update 额度按本批**通过校验的**
555
+ // update 数扣减——validateDecisions 已把非法条目从 decisions 就地 splice 掉,
556
+ // 所以数到的都是真的会落地的那些。落地时被 CAS 挡下的也算:额度宁可少发一次,
557
+ // 也不能让"多批各扣一次满额"把整轮上限架空。
558
+ const updateUsed = decisions.filter((d) => d?.action === "update").length;
559
+ // Issue #126 review(Copilot):事务内 service.update 会跳过 scheduleEmbed(txDepth>0),
560
+ // 而 sleep 路径此前没有 dream 那样的 post-apply 索引维护——differentiate 的差异注记与
561
+ // supersede 的归档都反映不到缓存向量,"注记进 embedding 防再判重复"就是空承诺。
562
+ if (applied > 0 && semantic?.embedder && semantic?.vectorIndex) {
563
+ try {
564
+ await maintainIndexAfterDream(decisions, service, semantic);
565
+ } catch (error) {
566
+ logger?.warn?.(`dsh-mneme sleep: index maintenance failed: ${String(error)}`);
567
+ }
568
+ }
569
+ // 台账:本批**全部送审对**都记一行,不只记被判成冲突的那几对。判 keep 的那一对
570
+ // 正是 #529 里"下一轮原样再送"的元凶,只记有动作的对等于没记。
571
+ if (typeof service.saveConflictReviewed === "function") {
572
+ // judged_at 取的是**发送之前**的时刻(见函数开头),不是落地之后:LLM 往返
573
+ // 期间若一侧被外部改动,它的 updated_at 会晚于 judged_at,下一轮自然重新入池;
574
+ // 模型看到的本来就是旧快照,按"已审"封盘等于用陈旧结论锁死这一对。
575
+ const outcomeOf = new Map();
576
+ for (const d of decisions) {
577
+ const action = d?.action ?? "keep";
578
+ if (action === "conflict" || action === "supersede") {
579
+ if (typeof d?.winner === "string") outcomeOf.set(d.winner, action);
580
+ if (typeof d?.loser === "string") outcomeOf.set(d.loser, action);
581
+ } else if (Array.isArray(d?.ids)) {
582
+ for (const id of d.ids) outcomeOf.set(id, action);
583
+ }
584
+ }
585
+ // Issue #536 review(CodeRabbit ③):未落地的对不记账。skipped(非法被跳)、
586
+ // failures(写入失败)、conflicts(CAS 冲突)里出现过的 id,说明那条决策没生效;
587
+ // 把它记成"已审"会让下一轮的 alreadyJudged 为真,这一对从此不再送审——
588
+ // 沉默地永久压制一个真实冲突,比多送一轮贵得多。
589
+ const unsettled = new Set();
590
+ for (const f of [...(failures ?? []), ...(conflicts ?? []), ...(skipped ?? [])]) {
591
+ for (const id of f?.ids ?? []) unsettled.add(id);
592
+ }
593
+ for (const p of batch) {
594
+ if (unsettled.has(p.a.id) || unsettled.has(p.b.id)) continue;
595
+ try {
596
+ service.saveConflictReviewed({
597
+ run_id: runId,
598
+ memory_a: p.a.id,
599
+ memory_b: p.b.id,
600
+ // 一对两端的动作可能不同(conflict 的胜/败、merge 的两端),取**非 keep
601
+ // 的那一侧**:keep/keep 才代表"看过、不冲突",这正是我们要留痕的结论。
602
+ outcome: [outcomeOf.get(p.a.id), outcomeOf.get(p.b.id)]
603
+ .find((x) => x && x !== "keep") ?? "keep",
604
+ judged_at: judgedAt
605
+ });
606
+ } catch (error) {
607
+ // Issue #529 review(CodeRabbit):留痕失败必须让这一轮**不 clean**。只 warn 的
608
+ // 话,这一对既没记账、又被算成"送出去的全落地了",下一轮它原样回到候选池、
609
+ // 又被算成可续排——每个静默窗口重问一次同一个冲突,直到连击上限才停,而
610
+ // 审计上每一轮都是干净的成功轮。
611
+ ledgerFailed += 1;
612
+ logger?.warn?.(`dsh-mneme sleep: failed to record reviewed conflict ${p.a.id}/${p.b.id}: ${String(error)}`);
613
+ }
614
+ }
615
+ }
616
+ // Issue #126 review(Copilot):有决策被跳过时不能报 ok——与 dream 的 degraded 契约
617
+ // 对齐("合法子集已应用"是 degraded 而非 ok),否则消费方分不清整轮与残轮。
618
+ return { status: applied > 0 ? (skipped.length > 0 ? "degraded" : "ok") : failures.length ? "failed" : "noop", applied, failures, conflicts, skipped, updateUsed, ledgerFailed };
619
+ };
620
+ // 分批:每批独立往返,坏批只记 degraded 不中断。此前一次全送,任何一处理由
621
+ // (撞上下文、输出截断、JSON 坏掉)都是整轮 failed——而整轮 failed 又不带 pairs,
622
+ // 事后看不出它到底送了什么。
623
+ const batchSize = config.sleepConflictMaxPairs ?? 12;
624
+ const batches = batchSize > 0
625
+ ? Array.from({ length: Math.ceil(pool.length / batchSize) }, (_, i) => pool.slice(i * batchSize, (i + 1) * batchSize))
626
+ : [pool];
627
+ let applied = 0;
628
+ const failures = [];
629
+ const conflicts = [];
630
+ const skipped = [];
631
+ let failedBatches = 0;
632
+ // Issue #529 review(CodeRabbit ①):台账写入失败的累计数(逐批由 adjudicateBatch 报回)。
633
+ let ledgerFailed = 0;
634
+ // 用户活动打断时**不能**落进下面那条 noop:noop 的语义是「看过,没冲突」,而被打断
635
+ // 的那几批根本没送出去。用与入口处(signal 一开始就是 aborted)同一个词汇,避免
636
+ // 同一件事两个状态名。
637
+ let interrupted = false;
638
+ // Issue #536 review(CodeRabbit ②):reflectionUpdateMaxPerRun 是**整轮**上限。
639
+ // 校验器本身不跨批记忆,每批各拿一份满额就等于 N 倍上限,所以额度由这里持有:
640
+ // 每批只领剩余额度,校验通过后按**本批通过校验的** update 数扣减。额度用尽的
641
+ // 那几批若还含 update,会被校验器整单拒绝(记 failed),其余批照常继续——
642
+ // 与"一批坏掉不判死整轮"的既有契约一致。
643
+ let updateLeft = config.reflectionUpdateMaxPerRun ?? 2;
644
+ for (let i = 0; i < batches.length; i++) {
645
+ if (signal?.aborted) { interrupted = true; break; }
646
+ // Issue #536 review(CodeRabbit):单批抛错不能带走整轮。`adjudicateBatch` 里不只有
647
+ // "把失败变成返回值"的 LLM 通路,还有 `applyDecisions` 与台账写入——它们抛出的异常
648
+ // 此前会直接跳出循环,后面所有批都不跑、已汇总的 applied/failures 也一起丢掉。
649
+ // 只放行 AbortError(取消意图绝不能被降级重试吞掉,与 dream.js 的 withEffort 守卫同款),
650
+ // 其余一律折成这一批的 failed 结果,循环继续。
651
+ let res;
411
652
  try {
412
- await maintainIndexAfterDream(decisions, service, semantic);
653
+ res = await adjudicateBatch(batches[i], updateLeft);
413
654
  } catch (error) {
414
- logger?.warn?.(`dsh-mneme sleep: index maintenance failed: ${String(error)}`);
655
+ if (error?.name === "AbortError") { interrupted = true; break; }
656
+ res = { status: "failed", error: String(error?.message ?? error), failures: [], skipped: [] };
415
657
  }
658
+ if (res.status === "failed") {
659
+ failedBatches++;
660
+ failures.push(...(res.failures ?? []));
661
+ skipped.push(...(res.skipped ?? []));
662
+ // 失败批的原始理由(stream 层的 abort/error 原文)要进审计:只写 "llm failed"
663
+ // 的话,运维分不清是上下文超限、模型拒收还是用户活动打断。
664
+ logger?.warn?.(`dsh-mneme sleep: conflict batch ${i + 1}/${batches.length} failed: ${res.error}${res.failure ? ` (${res.failure})` : ""}`);
665
+ continue;
666
+ }
667
+ applied += res.applied;
668
+ ledgerFailed += res.ledgerFailed ?? 0;
669
+ updateLeft = Math.max(0, updateLeft - (res.updateUsed ?? 0));
670
+ failures.push(...(res.failures ?? []));
671
+ conflicts.push(...(res.conflicts ?? []));
672
+ skipped.push(...(res.skipped ?? []));
416
673
  }
417
- // Issue #126 review(Copilot):有决策被跳过时不能报 ok——与 dream 的 degraded 契约
418
- // 对齐("合法子集已应用"是 degraded 而非 ok),否则消费方分不清整轮与残轮。
419
- const degraded = skipped.length > 0;
674
+ const degraded = skipped.length > 0 || failedBatches > 0;
675
+ // 一批都没成功才算整轮失败;只要有一批落地,如实记 degraded——这跟 deriveStatus
676
+ // 的契约一致(degraded 也算有产出)。
677
+ const allFailed = failedBatches === batches.length;
678
+ // 有一批没走通时整轮**绝不能报 noop**:noop 的语义是「看过,没冲突」,而踩了
679
+ // llm failed 的那批其实没看过。这是 #529 里最误导人的一处——23 轮失败期间
680
+ // applied 恒为 0,若再叠上 noop,运维只会以为库里没冲突。
681
+ const status = interrupted
682
+ ? "aborted"
683
+ : allFailed
684
+ ? "failed"
685
+ : applied > 0
686
+ ? (degraded ? "degraded" : "ok")
687
+ : failures.length
688
+ ? "failed"
689
+ : failedBatches > 0
690
+ ? "degraded"
691
+ : "noop";
692
+ // Issue #529(续排):「这一轮有没有产出」和「送出去的每一对是不是都落地了」是两件
693
+ // 正交的事,status 只回答前者。全判 keep 的轮次 applied 恒为 0 → status 是 noop
694
+ // (never ok),而它恰恰是 #529 里最该续排的场景;拿 status === "ok" 当判据会在
695
+ // 这里把续排堵死。所以另给一个布尔,口径与下面台账的 unsettled 完全一致:failure
696
+ // (写入失败)/ conflicts(CAS 冲突)/ skipped(非法被跳)任一出现过,就说明有对没
697
+ // 落地,它下一轮还会被重新配出来,不算 clean。
698
+ // Issue #529 review(CodeRabbit ①):台账写入失败同样算"没落地"——那一对没被记成
699
+ // 已审,下一轮会重新入池,与上面 unsettled 是同一个道理。
700
+ const clean =
701
+ !interrupted &&
702
+ failedBatches === 0 &&
703
+ skipped.length === 0 &&
704
+ failures.length === 0 &&
705
+ conflicts.length === 0 &&
706
+ ledgerFailed === 0;
420
707
  return {
421
- status: applied > 0 ? (degraded ? "degraded" : "ok") : failures.length ? "failed" : "noop",
708
+ status,
709
+ clean,
710
+ // Issue #529 D:失败轮也要带 pairs/batches——此前只有成功路径带 pairs,85 条
711
+ // 失败审计行全是"送了个寂寞",连送了多少对都查不到。
422
712
  pairs: selected.length,
713
+ // #533:这一轮被账本压掉、没送 LLM 的候选对数——不记的话「pairs 变小了」
714
+ // 分不清是没检测到还是已经裁决过。
715
+ suppressed,
716
+ batches: batches.length,
717
+ ledgerSkipped,
718
+ perRunSkipped,
423
719
  applied,
720
+ // Issue #529 review(CodeRabbit ①):留痕失败的对数——clean 为 false 时要能分清是
721
+ // 哪一类没落地(写入失败 / 非法被跳 / CAS 冲突 / 台账没记上)。
722
+ ledgerFailed,
424
723
  failures,
425
724
  conflicts,
426
725
  // Issue #126:#104 同向——被跳过的逐对明细要进审计行,否则事后无法判断
@@ -444,7 +743,10 @@ function phaseDemotion(service, config, logger, runId, signal = null) {
444
743
  // 政策轴在这里尤其要紧:下面两行会真改归档面(setArchived / demoteToSummary)。
445
744
  // 把过滤放在循环之外、而不是在循环体里 continue:一旦将来有人在循环体里加
446
745
  // 副作用(现已有),漏掉那条 continue 就是静默改到被按下的行。
447
- for (const m of withoutPaused(service, service.all())) {
746
+ // 外来行也在循环之外挡掉,同一个理由:它不经本方蒸馏,时间分层无权动它的原文或
747
+ // 归档面;而下面两行动作(setArchived / demoteToSummary)不经 saveWithDedupe,
748
+ // 汇流口那道闸门到这里已经管不到了。
749
+ for (const m of withoutPaused(service, service.all().filter((m) => !isImportedRow(m)))) {
448
750
  if (signal?.aborted) break;
449
751
  if (m.archived || m.forgotten) continue;
450
752
  const ref = m.last_accessed_at ?? m.created_at;
@@ -495,9 +797,31 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
495
797
  // 先生效的话,指针行一多就会把普通记忆挤出扫描窗,池子饿到门槛以下。
496
798
  // 政策轴同理必须排在 `.slice(0, limit)` **之前**,否则被按下的行照样占掉
497
799
  // sleepPatternMinMemories 的名额,而它们既不会被扫、也不该挤掉别人(#471)。
498
- const memories = withoutPaused(service, service
499
- .list({ limit: 200, includeForgotten: false, excludeTypes: ["document"] })
500
- .filter((m) => !m.archived && m.type !== "summary" && m.type !== "pattern"))
800
+ //
801
+ // archived 这一轴不在下面出现,不是漏了:list() 默认就带 `archived = 0`
802
+ // (store.js list 的 includeArchived 默认 false、onlyArchived 默认 false),
803
+ // 旧写法那句 `!m.archived` 一直是重复过滤——查询层已经把它挡在外面。
804
+ // 窗口至少等于本次要扫的名额:`sleepPatternMinMemories` 的合法上限是 1000
805
+ // (src/config.js:612)。窗口若还是死的 200,那么把这个值配到 200 以上的人
806
+ // **每一轮**都会触发下面那次无界重取,跟闸门按没按下无关——那是把这条修复
807
+ // 从「按需」变成「常付」。
808
+ const poolQuery = {
809
+ limit: Math.max(200, limit), includeForgotten: false,
810
+ // 三条政策轴全部下推到查询层:留在 LIMIT 之后过滤,等于让被丢掉的行为窗口占名额
811
+ // (模式扫描池会因此饿到 sleepPatternMinMemories 以下而静默跳过)。同一取舍见
812
+ // #230 的 document 轴与本文件 #471 的暂停轴,src/service.js:2247 那条注释记着前例。
813
+ excludeTypes: ["document", "summary", "pattern"],
814
+ excludeImported: true
815
+ };
816
+ const gather = (query) => withoutPaused(service, service.list(query));
817
+ let pool = gather(poolQuery);
818
+ // #515:暂停闸门要读 scope_state 并按「这一维有标注」匹配,下推进 SQL 就等于让判定
819
+ // 长出第二份实现(issue 里写过为什么否掉,`*` 那一行是关键的不可下推处)。所以这里
820
+ // 不动判定、只放宽取数:**只有窗口过滤后不够本次要扫的名额**才不带窗口重取一次。
821
+ // 常态仍只付一次有界查询;多付的那一次恰好就是原本会静默跳过的那一次——
822
+ // 症状从「库里没东西可扫」变成「扫了」,这才是这条闸门该有的行为。
823
+ if (pool.length < limit) pool = gather({ ...poolQuery, limit: null });
824
+ const memories = pool
501
825
  .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1))
502
826
  .slice(0, limit);
503
827
  if (memories.length === 0) return { status: "skipped", reason: "no memories to scan" };
@@ -518,7 +842,10 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
518
842
  relatedMemoryIds: memories.map((m) => m.id),
519
843
  streamError: () => patternStreamFailure,
520
844
  // 纯判据,同 conflict 阶段:不把解析结果存进闭包供控制流使用。
521
- auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output")
845
+ auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output"),
846
+ // 纯判据,与 auditError 同一条约定:重解析一次、不读闭包(审计关掉时那些闭包
847
+ // 根本不会被赋值,读它会让「审计说了什么」依赖调用方走到了哪一行)。
848
+ metadataFrom: (text) => (parseJsonArray(text) ? null : parseFailureMeta(text))
522
849
  }, (reportUsage) => streamText(ctx, {
523
850
  provider: route.provider,
524
851
  model: route.model,
@@ -527,7 +854,7 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
527
854
  ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
528
855
  messages: [
529
856
  { role: "system", content: [{ type: "text", text: STR.prompts.pattern[language].replace("N", String(maxPatterns)) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
530
- { role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
857
+ { role: "user", content: [{ type: "text", text: fenceListBody(language, listText) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
531
858
  ]
532
859
  }, reportUsage, (reason) => { patternStreamFailure = describeStreamFailure(reason); }));
533
860
  };
@@ -554,8 +881,10 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
554
881
  if (!ok) return { status: "failed", error: `invalid decisions: ${errors.join("; ")}` };
555
882
  // 本相位的 create 落 source=sleep。来源从管道给而非从决策对象取——见
556
883
  // applyDecisions 的 @param config。
884
+ // #512:runId 也必须给——这是 sleep 唯一的真实铸造路径,漏了它,铸造出来的
885
+ // pattern 就只能追到「sleep 这条管道」,追不到「哪一轮 sleep」(正是 #512 要补的口子)。
557
886
  const { applied, failures, conflicts } = applyDecisions(
558
- decisions, service, logger, snapshot, { ...config, mintSource: "sleep" }
887
+ decisions, service, logger, snapshot, { ...config, mintSource: "sleep", runId }
559
888
  );
560
889
  return {
561
890
  status: applied > 0 ? "ok" : "noop",
@@ -586,7 +915,7 @@ function phaseRelations(service, config, logger, runId, signal = null) {
586
915
  if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
587
916
  const orphans = entities.filter((e) => (service.getRelations(e.id) ?? []).length === 0);
588
917
  if (orphans.length === 0) return { status: "skipped", reason: "no orphan entities" };
589
- const memories = withoutPaused(service, service.all().filter((m) => !m.archived && !m.forgotten));
918
+ const memories = withoutPaused(service, service.all().filter((m) => !isImportedRow(m) && !m.archived && !m.forgotten));
590
919
  const seen = new Set();
591
920
  const related = [];
592
921
  const MAX_RELATIONS_PER_ORPHAN = 3;
@@ -631,10 +960,16 @@ function deriveStatus(phases) {
631
960
  // 跳过),它同样算"有产出"——否则整轮会被误判成 noop,把真实变更报成空轮。
632
961
  const anyWork = list.some((p) => p.status === "ok" || p.status === "degraded");
633
962
  const anyDegraded = list.some((p) => p.status === "degraded");
963
+ // Issue #529:被打断(用户恢复活动)既不是失败也不是"没活干"。相位层已经用
964
+ // aborted 区分了这两种,整轮层若回落到 noop,审计行就又把"没跑完"记成"跑过、
965
+ // 没什么事"——所以这里也给它一个自己的名字。只在没有任何产出时生效:真的落了
966
+ // 东西就是 ok/degraded,真的报错就是 failed,二者都比 aborted 值得优先播报。
967
+ const anyAborted = list.some((p) => p.status === "aborted");
634
968
  if (anyDegraded) return "degraded";
635
969
  if (anyWork && anyError) return "degraded";
636
970
  if (anyError) return "failed";
637
971
  if (anyWork) return "ok";
972
+ if (anyAborted) return "aborted";
638
973
  return "noop";
639
974
  }
640
975
 
@@ -705,7 +1040,18 @@ export async function runSleep(ctx, service, config, logger, semantic = null, si
705
1040
  } catch (error) {
706
1041
  logger?.warn?.(`dsh-mneme sleep: failed to record audit run: ${String(error)}`);
707
1042
  }
708
- return { ok: status === "ok" || status === "degraded", status, runId, phases, receipt };
1043
+ // Issue #529(续排):给调度器的续排判据。两个条件缺一不可——
1044
+ // · 本轮**被预算截断**(conflict 相位报告还有没进这一轮的对):说明池子没清空;
1045
+ // · 本轮**clean**:送出去的每一对都落地了,不是为了追一个永远失败的批次空转。
1046
+ // `clean` 缺失时(该相位被 attempt 吞成 failed、或配置让它早退)按"不干净"算,
1047
+ // 宁可少追一轮,也不要在失败状态下把间隔压到静默窗口。
1048
+ const conflictPhase = phases.conflicts;
1049
+ const backlog = Number(conflictPhase?.perRunSkipped ?? 0) > 0;
1050
+ // clean 与 catchup 分开给:前者是"这一轮的活全落地了",后者是"池子没清空 + 前者"。
1051
+ // 合成一个字段会让"干净但没 backlog"看起来像出过问题,排查时反着读。
1052
+ const clean = conflictPhase?.clean === true;
1053
+ const catchup = backlog && clean;
1054
+ return { ok: status === "ok" || status === "degraded", status, clean, catchup, backlog, runId, phases, receipt };
709
1055
  }
710
1056
 
711
1057
  // ---------------------------------------------------------------- scheduler
@@ -738,6 +1084,36 @@ export function createSleepScheduler({
738
1084
  let disposed = false;
739
1085
  let idleTimer = null;
740
1086
  let sleepAbort = null;
1087
+ // Issue #529(C 项):同一进程内的连续失败计数。失败(run 报 status="failed"
1088
+ // 或抛错)+1,其余一律清零(ok/degraded/noop——noop 说明 LLM 通路是好的,
1089
+ // 只是没活干,不该被当成失败拖着退避)。纯内存变量、宿主重启归零:跨重启的
1090
+ // 冷却本来就由 lastRunAtSeed(#89/#291)负责,两者不重叠。
1091
+ let consecutiveFailures = 0;
1092
+ // Issue #529(续排):上一轮报告"还有对没进这一轮、且送出去的全落地了"——池子没清空,
1093
+ // 该接着追而不是等满一个最小间隔。只由 maybeSchedule 按 onRun 的返回置位,跑完一轮就
1094
+ // 重新判:池子追干净或某一轮不 clean 时自动落回 false,不需要额外的复位路径。
1095
+ let catchupWanted = false;
1096
+ // Issue #529(续排):进程内续排连击数。给"池子在长、每轮都 clean 但永远追不完"这类
1097
+ // 库留一道刹车——用户持续写入时池子进得比追得快,光看 clean 会变成永不落地的每五分钟
1098
+ // 一轮。超过上限就退回正常间隔,等下一个静默窗口重新起算。
1099
+ let catchupStreak = 0;
1100
+
1101
+ // Issue #529:有效最小间隔 = 基数 × 2^连续失败数。关闭或尚无失败时逐字节
1102
+ // 返回基数(默认关 = 行为与现状一致)。封顶取 max(基数, 24h) 而不是硬 24h:
1103
+ // 指数再大也不会把用户自己配的大基数(允许到 168h)压小。
1104
+ function effectiveMinIntervalMs() {
1105
+ // Issue #529(续排):还有 backlog 时把冷却项按 0 处理,让表落在**静默窗口**而不是
1106
+ // 最小间隔上。不新增配置键:本轮的账本/更新写入会重置 lastWriteAt,下一轮自然至少
1107
+ // 等一个 sleepIdleMinutes,这本身就是节流;再开一个键要连带 settings.js 白名单与
1108
+ // test/api.test.js 的旗标计数锁,不值得为一件事动三个文件。
1109
+ // 与连败互斥(同 issue 的第三条):连续失败 > 0 时根本不追——两个逻辑抢同一只闹钟
1110
+ // 最容易出"重排的是短的、判定的是长的"这种空转,互斥最省事。
1111
+ if (catchupWanted && consecutiveFailures <= 0) return 0;
1112
+ const base = (config.sleepMinIntervalHours ?? 8) * 3600000;
1113
+ if (config.sleepFailureBackoff !== true || consecutiveFailures <= 0) return base;
1114
+ const cap = Math.max(base, FAILURE_BACKOFF_CAP_MS);
1115
+ return Math.min(base * 2 ** consecutiveFailures, cap);
1116
+ }
741
1117
 
742
1118
  function armIdleTimer() {
743
1119
  if (disposed || idleTimer) return;
@@ -748,8 +1124,10 @@ export function createSleepScheduler({
748
1124
  idleTimer = null;
749
1125
  // Issue #187:false = 撞上 CD(或 run 失败)——此前这里直接丢弃,"CD 到期"
750
1126
  // 成了没有监听者的时刻,要等下一次写入才救回来。改为按剩余窗口重排。
1127
+ // Issue #529(续排):跑成了也未必收工——池子还有没进这一轮的对时要接着追,
1128
+ // 而此时已经没有待触发的写入了,不在这里补挂就永远等不到下一轮。
751
1129
  const ran = await maybeSchedule();
752
- if (!ran) armNextWindow();
1130
+ if (!ran || catchupWanted) armNextWindow();
753
1131
  }, delay);
754
1132
  idleTimer.unref?.();
755
1133
  }
@@ -763,16 +1141,25 @@ export function createSleepScheduler({
763
1141
  // lastRunAt === 0 表示从未跑过(此路径不该出现,防御性返回)。
764
1142
  if (lastRunAt <= 0) return;
765
1143
  const idleMs = (config.sleepIdleMinutes ?? 5) * 60000;
766
- const cdMs = (config.sleepMinIntervalHours ?? 8) * 3600000;
767
- const delay = Math.max(
1144
+ // Issue #529:这里必须和 shouldRun 用同一个有效间隔,否则退避期里重排的闹钟
1145
+ // 会在基数到点就响、然后被 shouldRun 否掉再重排——空转一串没有意义的定时器。
1146
+ const cdMs = effectiveMinIntervalMs();
1147
+ let delay = Math.max(
768
1148
  lastRunAt + cdMs - now(),
769
1149
  lastWriteAt + idleMs - now(),
770
1150
  0
771
1151
  ) + 1000;
1152
+ // Issue #529(续排):冷却项按 0 处理之后,静默项可能已经是负的(这一轮跑得久、
1153
+ // 期间没有新写入),算出来只剩那 1 秒——追 backlog 不该变成秒级连发。续排下限钉在
1154
+ // 一个静默窗口上:追得动就每轮追一批,但每批之间仍然让库安静下来。
1155
+ // 有写入时静默项会自己变大(账本行落库会重置 lastWriteAt),这里只是保证下限。
1156
+ if (catchupWanted) delay = Math.max(delay, idleMs + 1000);
772
1157
  idleTimer = setTimeoutFn(async () => {
773
1158
  idleTimer = null;
774
1159
  const ran = await maybeSchedule();
775
- if (!ran) armNextWindow();
1160
+ // Issue #529(续排):与 armIdleTimer 的回调同一条理由——追 backlog 的轮次
1161
+ // 跑完必须自己补挂,否则续排在第二张表就断了。
1162
+ if (!ran || catchupWanted) armNextWindow();
776
1163
  }, delay);
777
1164
  idleTimer.unref?.();
778
1165
  }
@@ -783,7 +1170,9 @@ export function createSleepScheduler({
783
1170
  if (at - lastWriteAt < (config.sleepIdleMinutes ?? 5) * 60000) return false;
784
1171
  // lastRunAt === 0 means never ran — the min-interval check must not block
785
1172
  // the very first cycle (a real run stamps a nonzero timestamp).
786
- if (lastRunAt > 0 && at - lastRunAt < (config.sleepMinIntervalHours ?? 8) * 3600000) return false;
1173
+ // Issue #529:连败退避开启时改用指数放大的有效间隔——恒定失败的模型不再按
1174
+ // 固定节奏连发(#529 实测连续 23 轮 llm failed),只会越打越稀。
1175
+ if (lastRunAt > 0 && at - lastRunAt < effectiveMinIntervalMs()) return false;
787
1176
  return true;
788
1177
  }
789
1178
 
@@ -819,13 +1208,60 @@ export function createSleepScheduler({
819
1208
  const result = await service.enqueue(() =>
820
1209
  onRun ? onRun(abort.signal) : Promise.resolve({ ok: true, skipped: true })
821
1210
  );
1211
+ // Issue #529:连败计数只在这里加减。判据优先看 result.status——runSleep 的
1212
+ // ok 只在 ok/degraded 时为真,noop(LLM 通路正常、只是没活干)和用户活动
1213
+ // 打断都会给出 ok:false,把它当失败会让退避在自己工作良好的库里越退越长。
1214
+ // 回落到 !result.ok 只是为了兼容注入的 no-op 桩(没有 status 字段)。
1215
+ // result 为空(最小测试替身)=完成且无失败,不动计数,与 #292 口径一致。
1216
+ if (result) {
1217
+ const failed = typeof result.status === "string" ? result.status === "failed" : !result.ok;
1218
+ consecutiveFailures = failed ? consecutiveFailures + 1 : 0;
1219
+ // Issue #529(续排):判据只认 runSleep 给的 catchup——它是"池子还有没进这一轮
1220
+ // 的对"且"送出去的每一对都落地了"二者的合取。**不要**在这里拿 status 反推:
1221
+ // 全判 keep 的轮次 applied 恒为 0、status 是 noop,恰好在最该追的场景失效。
1222
+ // 连续失败时不追(与退避互斥,见 effectiveMinIntervalMs),并把连击计数清零——
1223
+ // 失败说明这轮的"追"没有意义,重新从正常节奏起算。
1224
+ // 连击到顶就收手,并把连击清零——不清零的话这个进程从此再也不会追(streak 只增
1225
+ // 不减,之后每一轮都被同一道闸挡下),"等下一个静默窗口重新起算"就成了空话。
1226
+ // 收手必须落在 catchupWanted 这个状态上,不能只写在 effectiveMinIntervalMs 里:
1227
+ // 那里改成"返回基数"之后,armNextWindow 仍会按 catchupWanted 把闹钟钉在静默
1228
+ // 窗口上重排,于是每 5 分钟醒一次、每次都被 shouldRun 否掉再重排——没有 LLM
1229
+ // 调用,但定时器链永远不断。
1230
+ const wantCatchup = result.catchup === true && consecutiveFailures <= 0;
1231
+ if (!wantCatchup || catchupStreak + 1 >= CATCHUP_STREAK_CAP) {
1232
+ catchupStreak = 0;
1233
+ catchupWanted = false;
1234
+ } else {
1235
+ catchupStreak += 1;
1236
+ catchupWanted = true;
1237
+ }
1238
+ }
822
1239
  return !!(result && result.ok);
823
1240
  } catch (error) {
824
1241
  logger?.warn?.(`dsh-mneme sleep: run failed: ${error?.message ?? error}`);
1242
+ consecutiveFailures += 1; // Issue #529:抛错同样计入连败
1243
+ // Issue #529(续排):抛错也算失败,续排意图一并清掉——否则上一轮置位的状态会跟着
1244
+ // 逃出 try 块,让下一次排表仍按静默窗口的空转节奏走。
1245
+ catchupWanted = false;
1246
+ catchupStreak = 0;
825
1247
  return false;
826
1248
  } finally {
827
1249
  sleepAbort = null;
828
1250
  running = false;
1251
+ // Issue #529 review(CodeRabbit ②):运行期间用户写入会走 noteWrite(),它把闹钟
1252
+ // 按**那一次写入**重算了一遍——那可能是静默窗口的尾声。若这一轮结束时报告
1253
+ // catchup=true,那只表可能几秒后就要响,下一批紧接着开跑,于是「让库安静一个
1254
+ // 静默窗口再追」变成「跑完立刻再跑」。这里把已挂着的表撤掉重挂,交给
1255
+ // armNextWindow 从**此刻**算出整整一个静默窗口。
1256
+ // 反过来(不追的轮次)保留 noteWrite 挂的那只表:它是按用户最后一次写入算的,
1257
+ // 正是「用户写完了、安静满一个窗口再说」的意思。
1258
+ if (catchupWanted) {
1259
+ if (idleTimer) {
1260
+ clearTimeoutFn(idleTimer);
1261
+ idleTimer = null;
1262
+ }
1263
+ armNextWindow();
1264
+ }
829
1265
  }
830
1266
  }
831
1267