@modusensus/dsh-mneme 0.7.20 → 0.7.22

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.
package/src/dream.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { validateDecisions, applyDecisions } from "./dream/decisions.js";
2
2
  import { clusterMemories, findPotentialConflicts } from "./dream/clustering.js";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
- export { validateDecisions, applyDecisions, withEffortFallback };
4
+ export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure };
5
5
 
6
6
 
7
7
  // Extract the first JSON array from LLM output, tolerating markdown fences,
@@ -238,18 +238,34 @@ function buildRecordReceipts({ runId, committed, snapshot, policyEpoch }) {
238
238
  * surfaces as undefined. The caller decides how to treat an empty result.
239
239
  * `onUsage` (optional, Bug8) receives any usage chunk for token accounting.
240
240
  */
241
- async function streamText(ctx, options, onUsage) {
241
+ async function streamText(ctx, options, onUsage, onStreamError) {
242
242
  let text = "";
243
243
  for await (const chunk of ctx.llm.stream(options)) {
244
244
  if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
245
245
  if (chunk.type === "usage" && typeof onUsage === "function") onUsage(chunk);
246
246
  if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
247
+ // dsh-llm rc.1 turns adapter-stage failures (unknown provider route,
248
+ // UNSUPPORTED_REASONING_EFFORT from resolveCallWithInfo, …) into a
249
+ // terminal finish chunk instead of a throw — the cause rides in
250
+ // chunk.reason.failure {message, code}. Surface it, never swallow it.
251
+ if (typeof onStreamError === "function") {
252
+ try { onStreamError(chunk.reason); } catch { /* diagnostics only */ }
253
+ }
247
254
  return undefined;
248
255
  }
249
256
  }
250
257
  return text;
251
258
  }
252
259
 
260
+ /** One-line human-readable cause from a finish-chunk failure reason. */
261
+ function describeStreamFailure(reason) {
262
+ const failure = reason?.failure ?? reason ?? {};
263
+ const code = failure.code ? String(failure.code) : "";
264
+ const message = String(failure.message ?? failure.error ?? "");
265
+ if (code && message) return message.includes(code) ? message : `${code}: ${message}`;
266
+ return code || message;
267
+ }
268
+
253
269
  /**
254
270
  * Bug8: wrap a background LLM call so its token/time/status are recorded in the
255
271
  * llm_audit_logs table. Best-effort bookkeeping: a failure to WRITE the audit
@@ -279,9 +295,12 @@ async function runAuditedLlm(ctx, service, config, spec, body) {
279
295
  });
280
296
  if (result === undefined) {
281
297
  // stream aborted/errored: the caller treats undefined as a failed run;
282
- // record it as error here so the audit shows the truth.
298
+ // record it as error here so the audit shows the truth. spec.streamError
299
+ // (a getter) lets the caller attach the finish-chunk cause so the audit
300
+ // row names it instead of a bare "aborted".
283
301
  status = "error";
284
- errorMessage = errorMessage ?? "llm stream aborted or errored";
302
+ const streamErr = typeof spec.streamError === "function" ? String(spec.streamError() ?? "") : "";
303
+ errorMessage = errorMessage ?? (streamErr ? `llm stream aborted or errored (${streamErr})` : "llm stream aborted or errored");
285
304
  } else if (typeof spec.auditError === "function") {
286
305
  // A stream that returned text but yields nothing usable is still a
287
306
  // failed call — record it as error, not the default success, so the
@@ -328,10 +347,22 @@ async function runAuditedLlm(ctx, service, config, spec, body) {
328
347
  * accepted → reasoning capped; rejected → provider default (old behavior),
329
348
  * logged so the rejection is observable.
330
349
  */
331
- async function withEffortFallback(ctx, effort, attempt, fallback) {
350
+ async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError) {
332
351
  if (!effort || effort === "none") return attempt();
333
352
  try {
334
- return await attempt();
353
+ const result = await attempt();
354
+ if (result === undefined) {
355
+ // dsh-llm rc.1 streams a provider effort-rejection as a terminal error
356
+ // finish chunk (adapterStream catches everything, never throws) — match
357
+ // on the chunk's failure reason here or the retry below is dead code
358
+ // for the stream path.
359
+ const reason = String(getStreamError?.() ?? "");
360
+ if (/reasoning[\s_]*effort|UNSUPPORTED_REASONING_EFFORT/i.test(reason)) {
361
+ ctx.logger?.warn?.(`dsh-mneme dream: reasoningEffort "${effort}" rejected via stream (${reason}); retrying without it`);
362
+ return fallback();
363
+ }
364
+ }
365
+ return result;
335
366
  } catch (error) {
336
367
  const message = String(error?.message ?? error);
337
368
  // matches both "reasoning effort" (natural language) and the bare
@@ -437,12 +468,15 @@ async function maintainIndexAfterDream(decisions, service, semantic) {
437
468
  if (embedder.modelHash) vectorIndex.markModel?.(embedder.modelHash, embedder.dimension);
438
469
  }
439
470
 
440
- export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, logger, semantic = null }) {
471
+ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, logger, semantic = null }) {
441
472
  let pendingTimer = null;
442
473
  let running = false;
443
474
  let disposed = false;
444
475
  let baseline = { count: 0, chars: 0 };
445
476
  let inFlight = null;
477
+ // Issue #89(请求 2):上一次实际开跑时刻。失败/degraded 的 run 也占用
478
+ // 最小间隔——节流的目的正是防止失败调用连发;间隔内的触发静默跳过。
479
+ let lastRunAt = 0;
446
480
 
447
481
  function shouldTrigger(service) {
448
482
  const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
@@ -455,11 +489,14 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
455
489
 
456
490
  function maybeSchedule(service) {
457
491
  if (disposed || running || pendingTimer) return false;
492
+ // Issue #89(请求 2):最小触发间隔闸门。
493
+ if (minIntervalMs > 0 && Date.now() - lastRunAt < minIntervalMs) return false;
458
494
  const { trigger, count, chars } = shouldTrigger(service);
459
495
  if (!trigger) return false;
460
496
  pendingTimer = setTimeout(() => {
461
497
  pendingTimer = null;
462
498
  running = true;
499
+ lastRunAt = Date.now();
463
500
  // Defer the onRun invocation so a synchronous throw cannot escape the
464
501
  // timer callback (which would crash the process) and skip the teardown.
465
502
  // Errors are logged, never swallowed silently. inFlight lets dispose()
@@ -630,11 +667,15 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
630
667
  // 记 audit error 并在日志带原始输出前 300 字节,便于定位"推理吞预算返回空体"。
631
668
  const effort = config.dreamReasoningEffort && config.dreamReasoningEffort !== "none" ? config.dreamReasoningEffort : null;
632
669
  let decisions = null;
633
- const runConsolidation = (withEffort) => runAuditedLlm(ctx, service, config, {
670
+ let streamFailure = "";
671
+ const runConsolidation = (withEffort) => {
672
+ streamFailure = "";
673
+ return runAuditedLlm(ctx, service, config, {
634
674
  triggerSource: "autoDream",
635
675
  operationType: "dream_consolidate",
636
676
  modelId: `${route.provider}:${route.model}`,
637
677
  relatedMemoryIds: [...snapshot.keys()],
678
+ streamError: () => streamFailure,
638
679
  auditError: (text) => {
639
680
  decisions = extractJsonArray(text);
640
681
  return Array.isArray(decisions) ? null : "no json array in llm output";
@@ -649,18 +690,19 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
649
690
  { role: "system", content: [{ type: "text", text: consolidationPrompt }] },
650
691
  { role: "user", content: [{ type: "text", text: listText }] }
651
692
  ]
652
- }, reportUsage));
693
+ }, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); }));
694
+ };
653
695
  try {
654
696
  // Bug8: the consolidation call is audited (tokens/time/status). A throw
655
697
  // re-propagates to the catch below; an aborted stream returns undefined
656
698
  // and is treated as a failed run after the check below.
657
- decisionText = await withEffortFallback(ctx, effort, () => runConsolidation(true), () => runConsolidation(false));
699
+ decisionText = await withEffortFallback(ctx, effort, () => runConsolidation(true), () => runConsolidation(false), () => streamFailure);
658
700
  } catch (error) {
659
701
  logger?.warn?.(`dsh-mneme dream: consolidation llm call failed: ${String(error)}`);
660
702
  return finish({ ok: false, error: "llm failed", summary: false });
661
703
  }
662
704
  if (decisionText === undefined) {
663
- logger?.warn?.("dsh-mneme dream: consolidation llm stream aborted or errored");
705
+ logger?.warn?.(`dsh-mneme dream: consolidation llm stream aborted or errored${streamFailure ? ` (${streamFailure})` : ""}`);
664
706
  return finish({ ok: false, error: "llm failed", summary: false });
665
707
  }
666
708
  if (!Array.isArray(decisions)) {
@@ -668,18 +710,26 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
668
710
  logger?.warn?.(`dsh-mneme dream: no json array in llm output (raw length ${decisionText?.length ?? 0}; head: ${head})`);
669
711
  return finish({ ok: false, error: "no json array in llm output", summary: false });
670
712
  }
671
- const { ok, errors } = validateDecisions(decisions, snapshot, {
713
+ const { ok, errors, skipped } = validateDecisions(decisions, snapshot, {
672
714
  maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
673
715
  minAgeHours: config.reflectionUpdateMinAgeHours,
674
716
  // v0.4.4 fix:显式透传,用户配 dreamImplicitKeep:false 时严格模式必须
675
717
  // 真正生效,dreamMinExplicitCoverage 决定隐式 keep 下的覆盖率下限。
676
718
  dreamImplicitKeep: config.dreamImplicitKeep,
677
- dreamMinExplicitCoverage: config.dreamMinExplicitCoverage
719
+ dreamMinExplicitCoverage: config.dreamMinExplicitCoverage,
720
+ // Issue #89:v0.6.9(Issue #26)的宽容路径在 v0.7.11 重写中丢失——单条
721
+ // 非法决策重新只跳过该条、合法子集照常应用(run 记为 degraded)。
722
+ skipInvalid: config.dreamSkipInvalid !== false,
723
+ allowCrossTypeMerge: config.allowCrossTypeMerge === true
678
724
  });
679
725
  if (!ok) {
680
726
  logger?.warn?.(`dsh-mneme dream: invalid decisions: ${errors.join("; ")}`);
681
727
  return finish({ ok: false, error: `invalid decisions: ${errors.length} errors`, summary: false });
682
728
  }
729
+ const skippedInvalid = skipped.length > 0;
730
+ if (skippedInvalid) {
731
+ logger?.warn?.(`dsh-mneme dream: ${skipped.length} invalid decision(s) skipped (run degrades): ${skipped.map((s) => s.error).join("; ")}`);
732
+ }
683
733
 
684
734
  // Capture pre-update snapshots so the audit records what each update changed.
685
735
  const updateSnapshots = {};
@@ -782,11 +832,15 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
782
832
  // Summary generation (second LLM call). A throwing stream is reported as
783
833
  // a failed run; summary:false marks a run that produced no summary.
784
834
  let summaryText;
785
- const runSummary = (withEffort) => runAuditedLlm(ctx, service, config, {
835
+ let summaryStreamFailure = "";
836
+ const runSummary = (withEffort) => {
837
+ summaryStreamFailure = "";
838
+ return runAuditedLlm(ctx, service, config, {
786
839
  triggerSource: "autoDream",
787
840
  operationType: "dream_summarize",
788
841
  modelId: `${route.provider}:${route.model}`,
789
- relatedMemoryIds: []
842
+ relatedMemoryIds: [],
843
+ streamError: () => summaryStreamFailure
790
844
  }, (reportUsage) => streamText(ctx, {
791
845
  provider: route.provider,
792
846
  model: route.model,
@@ -797,10 +851,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
797
851
  { role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
798
852
  { role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
799
853
  ]
800
- }, reportUsage));
854
+ }, reportUsage, (reason) => { summaryStreamFailure = describeStreamFailure(reason); }));
855
+ };
801
856
  try {
802
857
  // Bug8: the summary call is audited too (operation dream_summarize).
803
- summaryText = await withEffortFallback(ctx, effort, () => runSummary(true), () => runSummary(false));
858
+ summaryText = await withEffortFallback(ctx, effort, () => runSummary(true), () => runSummary(false), () => summaryStreamFailure);
804
859
  } catch (error) {
805
860
  logger?.warn?.(`dsh-mneme dream: summary llm call failed: ${String(error)}`);
806
861
  return finish({ ok: false, error: "llm failed", applied, decisions: auditDecisions, outcome, frozen: frozenCount, summary: false });
@@ -831,9 +886,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
831
886
  // run. ok:false keeps the scheduler from moving the baseline.
832
887
  // ok — either real changes landed, or a fresh summary was stored
833
888
  // (all-keep + summary is a substantive summary refresh).
834
- // degraded — real consolidation landed but the summary came back empty/
835
- // missing: the store was absorbed (ok for the baseline) but
836
- // the run did not produce its full output (marked, not faked).
889
+ // degraded — real consolidation landed but the run did not produce its
890
+ // full output: the summary came back empty/missing, or
891
+ // skipInvalid dropped individually-invalid decisions
892
+ // (Issue #89 — marked, not faked). The valid subset was
893
+ // absorbed (ok for the baseline).
837
894
  let status;
838
895
  let okResult;
839
896
  if (partial) {
@@ -843,7 +900,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
843
900
  status = summaryStored ? "ok" : "noop";
844
901
  okResult = summaryStored;
845
902
  } else {
846
- status = summaryStored ? "ok" : "degraded";
903
+ status = summaryStored && !skippedInvalid ? "ok" : "degraded";
847
904
  okResult = true;
848
905
  }
849
906
  return finish({
package/src/index.js CHANGED
@@ -283,6 +283,7 @@ export const apply = (ctx, config) => {
283
283
  thresholdCount: cfg.dreamThresholdCount,
284
284
  thresholdChars: cfg.dreamThresholdChars,
285
285
  delayMs: cfg.dreamDelayMs,
286
+ minIntervalMs: (cfg.dreamMinIntervalMinutes ?? 0) * 60000,
286
287
  logger: ctx.logger,
287
288
  semantic: { embedder, vectorIndex },
288
289
  onRun: () => (dream ? dream.runDream(ctx, service, cfg) : Promise.resolve({ ok: true, skipped: true }))
package/src/settings.js CHANGED
@@ -58,6 +58,9 @@ const FEATURE_FLAG_BOOLEANS = [
58
58
  "bm25SearchEnabled",
59
59
  "conflictFreezeEnabled",
60
60
  "trustEpistemicWeighting",
61
+ // Issue #89:宽容校验回归(默认开)+ 跨类型合并显式放宽(默认关)。
62
+ "dreamSkipInvalid",
63
+ "allowCrossTypeMerge",
61
64
  // 嵌套对象开关:config.js 里是 memoryQualityFilter / llmAudit 对象的 enabled
62
65
  // 子字段。kv 按点号键平铺存("memoryQualityFilter.enabled": false),index.js
63
66
  // 合并时展开回嵌套对象,api.js 的 effective 从对象子字段取值。
@@ -70,7 +73,8 @@ const FEATURE_FLAG_INT_RANGES = {
70
73
  distillRateLimitRetries: [0, 10],
71
74
  distillRateLimitBaseDelayMs: [100, 60000],
72
75
  distillMaxChars: [1000, 200000],
73
- codingBoostFactor: [1, 5]
76
+ codingBoostFactor: [1, 5],
77
+ dreamMinIntervalMinutes: [0, 10080]
74
78
  };
75
79
  // 自由字符串开关(与 config.js 的 z.string() 同名同型):trim 后 ≤200 字符,
76
80
  // 空串合法(= 跟随主对话模型/默认路径,面板显示 placeholder)。
package/test/api.test.js CHANGED
@@ -527,10 +527,14 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
527
527
  assert.equal(res.statusCode, 200);
528
528
  const data = JSON.parse(res.body);
529
529
  assert.deepEqual(data.overrides, {});
530
- // effective 覆盖全部 31 个白名单键(含 v0.7.20 新增的 heatEnabled),未覆盖时
531
- // bundle 配置的解析默认值;dreamProvider/dreamModel 无 schema 默认值
532
- // (Config({}) 解析为 undefined),不编造给前端 31 - 2 = 29
533
- assert.equal(Object.keys(data.effective).length, 29);
530
+ // effective 覆盖全部 34 个白名单键(含 v0.7.20 heatEnabled 与 Issue #89 回归
531
+ // 修复新增的 dreamSkipInvalid/allowCrossTypeMerge/dreamMinIntervalMinutes),
532
+ // 未覆盖时取 bundle 配置的解析默认值;dreamProvider/dreamModel schema
533
+ // 默认值(Config({}) 解析为 undefined),不编造给前端 → 34 - 2 = 32
534
+ assert.equal(Object.keys(data.effective).length, 32);
535
+ assert.equal(data.effective.dreamSkipInvalid, true);
536
+ assert.equal(data.effective.allowCrossTypeMerge, false);
537
+ assert.equal(data.effective.dreamMinIntervalMinutes, 0);
534
538
  assert.equal(data.effective.autoInject, true);
535
539
  assert.equal(data.effective.codingRetrospect, false);
536
540
  assert.equal(data.effective.distillMaxChars, 24000);
@@ -899,3 +899,175 @@ test("Bug8: a failed LLM call is recorded with status=error and does not block t
899
899
  assert.ok(rows[0].error_message, "error message recorded");
900
900
  store.close();
901
901
  });
902
+
903
+ // --- Issue #89 回归修复:v0.6.9(Issue #26)的 skipInvalid 宽容路径在 v0.7.11
904
+ // 重写中丢失,弱模型(如 qwen3.8-flash)单条非法决策导致整单拒绝、合法子集
905
+ // 全部白烧。以下单测自 v0.6.9 测试原样移植,锁定恢复后的行为。-------------
906
+
907
+ test("validateDecisions skipInvalid: a single invalid decision is skipped, valid subset survives", () => {
908
+ const snap = new Map([
909
+ ["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
910
+ ["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }],
911
+ ["a", { id: "a", type: "project", title: "旧A", content: "过时A", importance: 3, archived: false, forgotten: false }],
912
+ ["b", { id: "b", type: "project", title: "旧B", content: "过时B", importance: 3, archived: false, forgotten: false }]
913
+ ]);
914
+ const decisions = [
915
+ { action: "merge", ids: ["p", "j"], keepSource: "p", title: "跨类型", content: "不应合并", importance: 4 },
916
+ { action: "archive", ids: ["a"], reason: "stale" },
917
+ { action: "archive", ids: ["b"], reason: "stale" }
918
+ ];
919
+ const { ok, errors, skipped } = validateDecisions(decisions, snap, { skipInvalid: true });
920
+ assert.equal(ok, true, `valid subset should survive, got: ${errors.join("; ")}`);
921
+ assert.equal(skipped.length, 1, "cross-type merge recorded as skipped");
922
+ assert.equal(skipped[0].index, 0, "the skipped one is the cross-type merge");
923
+ assert.match(skipped[0].error, /multiple types/, "skip reason mentions types");
924
+ // invalid merge spliced out of the caller's array; valid archives + implicit
925
+ // keeps for p/j survive (p/j were left unclaimed by the skipped merge)
926
+ assert.deepEqual(decisions.map((d) => d.action), ["archive", "archive", "keep", "keep"]);
927
+ assert.deepEqual(decisions[0].ids, ["a"]);
928
+ assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("p")), "p auto-kept");
929
+ assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("j")), "j auto-kept");
930
+ });
931
+
932
+ test("validateDecisions skipInvalid: an all-invalid batch still rejects (coverage floor guards truncation)", () => {
933
+ const snap = new Map([
934
+ ["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
935
+ ["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }],
936
+ ["d1", { id: "d1", type: "decision", title: "决定", content: "内容D", importance: 3, archived: false, forgotten: false }],
937
+ ["b", { id: "b", type: "project", title: "旧B", content: "过时B", importance: 3, archived: false, forgotten: false }]
938
+ ]);
939
+ // both decisions are cross-type merges → both skipped → valid claims = 0
940
+ const decisions = [
941
+ { action: "merge", ids: ["p", "j"], keepSource: "p", title: "跨类型", content: "不应合并", importance: 4 },
942
+ { action: "merge", ids: ["d1", "b"], keepSource: "d1", title: "跨类型2", content: "不应合并", importance: 4 }
943
+ ];
944
+ const { ok, errors, skipped } = validateDecisions(decisions, snap, { skipInvalid: true });
945
+ assert.equal(ok, false, "no valid decisions left → whole batch rejected");
946
+ assert.equal(skipped.length, 2);
947
+ assert.ok(errors.some((e) => e.includes("coverage")), "coverage error present");
948
+ assert.equal(decisions.length, 2, "rejected batch left untouched (splice only on the success path)");
949
+ });
950
+
951
+ test("validateDecisions skipInvalid: runaway update count still rejects the whole batch (global cap)", () => {
952
+ const snap = new Map([
953
+ ["a", { id: "a", type: "project", title: "A", content: "旧A", importance: 3, archived: false, forgotten: false, created_at: "2020-01-01T00:00:00.000Z" }],
954
+ ["b", { id: "b", type: "project", title: "B", content: "旧B", importance: 3, archived: false, forgotten: false, created_at: "2020-01-01T00:00:00.000Z" }],
955
+ ["c", { id: "c", type: "project", title: "C", content: "旧C", importance: 3, archived: false, forgotten: false, created_at: "2020-01-01T00:00:00.000Z" }]
956
+ ]);
957
+ const decisions = [
958
+ { action: "update", ids: ["a"], content: "新A" },
959
+ { action: "update", ids: ["b"], content: "新B" },
960
+ { action: "update", ids: ["c"], content: "新C" }
961
+ ];
962
+ const { ok, errors } = validateDecisions(decisions, snap, { skipInvalid: true });
963
+ assert.equal(ok, false, "3 updates > default cap 2 → still rejects");
964
+ assert.ok(errors.some((e) => e.includes("too many update decisions")), "global cap error present");
965
+ });
966
+
967
+ test("validateDecisions allowCrossTypeMerge: cross-type merge is allowed when the flag is on", () => {
968
+ const snap = new Map([
969
+ ["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
970
+ ["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }]
971
+ ]);
972
+ const decisions = [
973
+ { action: "merge", ids: ["p", "j"], keepSource: "p", title: "合并", content: "合并内容", importance: 4 }
974
+ ];
975
+ const { ok, errors } = validateDecisions(decisions, snap, { allowCrossTypeMerge: true });
976
+ assert.equal(ok, true, `cross-type merge allowed with the flag, got: ${errors.join("; ")}`);
977
+ assert.equal(decisions.length, 1, "no keep appended (both snapshot ids claimed)");
978
+ });
979
+
980
+ test("validateDecisions default (no options) stays strict — sleep passes unchanged", () => {
981
+ const snap = new Map([
982
+ ["a", { id: "a", type: "project", title: "A", content: "旧A", importance: 3, archived: false, forgotten: false }],
983
+ ["b", { id: "b", type: "project", title: "B", content: "旧B", importance: 3, archived: false, forgotten: false }]
984
+ ]);
985
+ const { ok, skipped } = validateDecisions([{ action: "archive", ids: ["zzz"], reason: "x" }], snap);
986
+ assert.equal(ok, false, "strict rejection without skipInvalid");
987
+ assert.deepEqual(skipped, [], "no skipped bookkeeping in strict mode");
988
+ });
989
+
990
+ test("issue#89: dream run with a skipped invalid decision lands the valid subset and is marked degraded", async () => {
991
+ const { store, service } = dreamSetup();
992
+ const a = service.saveWithDedupe({ type: "project", title: "旧A", content: "过时A" }).memory;
993
+ const b = service.saveWithDedupe({ type: "project", title: "旧B", content: "过时B" }).memory;
994
+ const pref = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }).memory;
995
+ const c = service.saveWithDedupe({ type: "project", title: "旧C", content: "过时C" }).memory;
996
+ const warnings = [];
997
+ const ctx = {
998
+ logger: { warn: (m) => warnings.push(String(m)) },
999
+ llm: {
1000
+ async *stream(options) {
1001
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
1002
+ if (userText.startsWith("id=")) {
1003
+ // 一条跨类型 merge(非法 → 跳过)+ 两条合法 archive(覆盖 2/4 快照,
1004
+ // 高于 50% 显式覆盖率下限)
1005
+ yield { type: "text-delta", index: 0, text: JSON.stringify([
1006
+ { action: "merge", ids: [pref.id, a.id], keepSource: pref.id, title: "跨类型", content: "不应合并", importance: 4 },
1007
+ { action: "archive", ids: [b.id], reason: "stale" },
1008
+ { action: "archive", ids: [c.id], reason: "stale" }
1009
+ ]) };
1010
+ } else {
1011
+ yield { type: "text-delta", index: 0, text: "记忆库总览:弱模型的个别非法决策不再拖垮整轮巩固。" };
1012
+ }
1013
+ yield { type: "finish", reason: { kind: "stop" } };
1014
+ }
1015
+ }
1016
+ };
1017
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1018
+ const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model" });
1019
+ assert.equal(result.ok, true, "valid subset absorbed (ok for the baseline)");
1020
+ assert.equal(result.status, "degraded", "run marked degraded, not faked ok");
1021
+ assert.equal(store.getById(b.id).archived, true, "valid archive applied");
1022
+ assert.equal(store.getById(pref.id).archived, false, "invalid merge did not touch its targets");
1023
+ assert.ok(warnings.some((w) => w.includes("skipped") && w.includes("multiple types")), "skip reason logged");
1024
+ store.close();
1025
+ });
1026
+
1027
+ test("issue#89: dreamSkipInvalid:false restores the whole-batch strict rejection", async () => {
1028
+ const { store, service } = dreamSetup();
1029
+ const a = service.saveWithDedupe({ type: "project", title: "旧A", content: "过时A" }).memory;
1030
+ const b = service.saveWithDedupe({ type: "project", title: "旧B", content: "过时B" }).memory;
1031
+ const pref = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" }).memory;
1032
+ const ctx = {
1033
+ logger: { warn: () => {} },
1034
+ llm: {
1035
+ async *stream(options) {
1036
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
1037
+ if (userText.startsWith("id=")) {
1038
+ yield { type: "text-delta", index: 0, text: JSON.stringify([
1039
+ { action: "merge", ids: [pref.id, a.id], keepSource: pref.id, title: "跨类型", content: "不应合并", importance: 4 },
1040
+ { action: "archive", ids: [b.id], reason: "stale" }
1041
+ ]) };
1042
+ } else {
1043
+ yield { type: "text-delta", index: 0, text: "记忆库总览。" };
1044
+ }
1045
+ yield { type: "finish", reason: { kind: "stop" } };
1046
+ }
1047
+ }
1048
+ };
1049
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
1050
+ const result = await dream.runDream(ctx, service, { dreamProvider: "mock", dreamModel: "mock-model", dreamSkipInvalid: false });
1051
+ assert.equal(result.ok, false, "strict mode rejects the batch");
1052
+ assert.equal(result.error, "invalid decisions: 1 errors");
1053
+ assert.equal(store.getById(b.id).archived, false, "nothing applied under strict rejection");
1054
+ store.close();
1055
+ });
1056
+
1057
+ test("issue#89: minIntervalMs throttles re-triggering regardless of run outcome", async () => {
1058
+ const { store, service } = dreamSetup();
1059
+ let runs = 0;
1060
+ const dream = createDreamScheduler({
1061
+ onRun: async () => { runs++; return { ok: false, error: "llm failed" }; },
1062
+ thresholdCount: 1, thresholdChars: 0, delayMs: 0, minIntervalMs: 80,
1063
+ logger: { warn: () => {} }
1064
+ });
1065
+ service.saveWithDedupe({ type: "project", title: "a", content: "x" });
1066
+ assert.equal(dream.maybeSchedule(service), true, "first trigger scheduled");
1067
+ await new Promise((r) => setTimeout(r, 20)); // delayMs 0 → run already dispatched and finished
1068
+ assert.equal(runs, 1, "ran once");
1069
+ assert.equal(dream.maybeSchedule(service), false, "inside the min interval, even after a failed run");
1070
+ await new Promise((r) => setTimeout(r, 90));
1071
+ assert.equal(dream.maybeSchedule(service), true, "interval elapsed → eligible again (baseline unmoved by failure)");
1072
+ store.close();
1073
+ });
@@ -234,3 +234,117 @@ test("issue#9: sleep forwards sleepReasoningEffort on its LLM passes", async ()
234
234
  }
235
235
  store.close();
236
236
  });
237
+
238
+ // ------------------------------------------------------------------ stream-level rejection
239
+ // dsh-llm rc.1 converts adapter-stage failures (including the provider's
240
+ // UNSUPPORTED_REASONING_EFFORT throw from resolveCallWithInfo) into a terminal
241
+ // error finish chunk inside adapterStream — the rejection NEVER reaches our
242
+ // catch. The v0.7.16 throw-based fallback was therefore dead code for the
243
+ // stream path; these tests pin the finish-chunk-based fallback.
244
+
245
+ test("rc.1 stream-level effort rejection (error finish chunk) also triggers the no-effort retry", async () => {
246
+ const store = createStore(":memory:");
247
+ const service = createService({ store, mirror: null, config: {} });
248
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
249
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
250
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
251
+ const calls = [];
252
+ const warnings = [];
253
+ const ctx = {
254
+ logger: { warn: (m) => warnings.push(String(m)) },
255
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
256
+ llm: {
257
+ async *stream(options) {
258
+ calls.push(options);
259
+ if (options.reasoningEffort) {
260
+ yield {
261
+ type: "finish",
262
+ reason: {
263
+ kind: "error",
264
+ failure: {
265
+ code: "UNSUPPORTED_REASONING_EFFORT",
266
+ message: 'provider "mock" model "mock-model" does not support reasoning effort "low"'
267
+ }
268
+ }
269
+ };
270
+ return;
271
+ }
272
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
273
+ if (userText.startsWith("id=")) {
274
+ yield { type: "text-delta", index: 0, text: JSON.stringify([
275
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
276
+ ]) };
277
+ } else {
278
+ yield { type: "text-delta", index: 0, text: "记忆库总览:用户偏好中文。" };
279
+ }
280
+ yield { type: "finish", reason: { kind: "stop" } };
281
+ }
282
+ }
283
+ };
284
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "low" });
285
+ assert.equal(result.ok, true, "run survives the stream-level effort rejection");
286
+ assert.ok(result.applied > 0, "consolidation still lands changes");
287
+ assert.equal(calls[0].reasoningEffort, "low", "first attempt forwards the effort");
288
+ assert.equal("reasoningEffort" in calls[1], false, "retry omits the rejected effort field");
289
+ assert.ok(warnings.some((w) => w.includes("rejected via stream")), "the stream-level rejection is logged");
290
+ store.close();
291
+ });
292
+
293
+ test("non-effort stream failures are not retried and the finish-chunk cause reaches the audit row", async () => {
294
+ const store = createStore(":memory:");
295
+ const service = createService({ store, mirror: null, config: {} });
296
+ service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
297
+ const calls = [];
298
+ const ctx = {
299
+ logger: { warn: () => {} },
300
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
301
+ llm: {
302
+ async *stream(options) {
303
+ calls.push(options);
304
+ yield { type: "finish", reason: { kind: "error", failure: { code: "PROVIDER_GONE", message: "provider mock is not registered" } } };
305
+ }
306
+ }
307
+ };
308
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
309
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "low" });
310
+ assert.equal(result.ok, false);
311
+ assert.equal(result.error, "llm failed", "the run error stays the stable short string");
312
+ assert.equal(calls.length, 1, "no blind retry when the stream failure is not an effort rejection");
313
+ const row = service.listLlmAudits().find((r) => r.operation_type === "dream_consolidate");
314
+ assert.ok(row && row.status === "error", "failed consolidation still audited");
315
+ assert.ok(
316
+ String(row.error_message).includes("PROVIDER_GONE") && String(row.error_message).includes("provider mock is not registered"),
317
+ "audit error_message carries the finish-chunk cause"
318
+ );
319
+ store.close();
320
+ });
321
+
322
+ test("sleep passes the stream failure accessor so a stream-level effort rejection retries", async () => {
323
+ const { store, service, vectorIndex } = sleepSetup();
324
+ const a = service.saveWithDedupe({ type: "project", title: "主题X", content: "内容A 关于主题X", importance: 3 }).memory;
325
+ const b = service.saveWithDedupe({ type: "project", title: "主题X副本", content: "内容B 关于主题X", importance: 3 }).memory;
326
+ vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
327
+ vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
328
+ const captured = [];
329
+ const ctx = sleepCtx(null, { provider: "mock", model: "sleep-model" }, captured);
330
+ ctx.llm.stream = async function* (options) {
331
+ captured.push(options);
332
+ if (options.reasoningEffort) {
333
+ yield {
334
+ type: "finish",
335
+ reason: { kind: "error", failure: { code: "UNSUPPORTED_REASONING_EFFORT", message: 'provider "mock" model "sleep-model" does not support reasoning effort "low"' } }
336
+ };
337
+ return;
338
+ }
339
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
340
+ yield { type: "text-delta", index: 0, text: userText.startsWith("候选冲突")
341
+ ? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
342
+ : "[]" };
343
+ yield { type: "finish", reason: { kind: "stop" } };
344
+ };
345
+ const result = await runSleep(ctx, service, baseConfig({ sleepReasoningEffort: "low" }), ctx.logger, { embedder, vectorIndex }, null);
346
+ assert.equal(result.status, "ok", "sleep survives the stream-level effort rejection");
347
+ assert.equal(captured[0].reasoningEffort, "low", "first conflict attempt forwards the effort");
348
+ assert.equal("reasoningEffort" in captured[1], false, "conflict retry omits the rejected effort field");
349
+ store.close();
350
+ });