@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/README.en.md +9 -1
- package/README.md +7 -0
- package/lib/config.js +12 -0
- package/lib/dream/decisions.js +105 -73
- package/lib/dream/sleep.js +29 -10
- package/lib/dream.js +78 -21
- package/lib/index.js +1 -0
- package/lib/settings.js +5 -1
- package/package.json +1 -1
- package/src/config.js +12 -0
- package/src/dream/decisions.js +105 -73
- package/src/dream/sleep.js +29 -10
- package/src/dream.js +78 -21
- package/src/index.js +1 -0
- package/src/settings.js +5 -1
- package/test/api.test.js +8 -4
- package/test/dream.test.js +172 -0
- package/test/reasoning-effort.test.js +114 -0
package/lib/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?.(
|
|
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
|
-
|
|
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
|
|
835
|
-
//
|
|
836
|
-
//
|
|
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/lib/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/lib/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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
3
|
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.22",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
package/src/config.js
CHANGED
|
@@ -47,6 +47,11 @@ export const Config = z.object({
|
|
|
47
47
|
dreamThresholdCount: z.natural().min(1).max(1000).default(10),
|
|
48
48
|
dreamThresholdChars: z.natural().min(100).max(100000).default(5000),
|
|
49
49
|
dreamDelayMs: z.natural().min(0).max(60000).default(2000),
|
|
50
|
+
// autoDream 触发最小间隔(分钟,0 = 不限制,Issue #89 请求 2):高频写入
|
|
51
|
+
// 场景下防止巩固调用(含失败重试)连发刷爆配额。间隔从每次实际开跑时刻
|
|
52
|
+
// 起算,失败/degraded 的 run 也占用间隔;间隔内的触发请求静默跳过,下一次
|
|
53
|
+
// 写入事件会重新评估。
|
|
54
|
+
dreamMinIntervalMinutes: z.natural().min(0).max(10080).default(0),
|
|
50
55
|
dreamProvider: z.string(),
|
|
51
56
|
dreamModel: z.string(),
|
|
52
57
|
dreamMaxTokens: z.natural().min(256).max(131072).default(8192),
|
|
@@ -74,6 +79,13 @@ export const Config = z.object({
|
|
|
74
79
|
// → 整单拒绝,防止残缺输出被隐式 keep 洗白成 ok 后再被真实 apply。0-1,
|
|
75
80
|
// 默认 0.5(至少显式覆盖一半 snapshot)。
|
|
76
81
|
dreamMinExplicitCoverage: z.number().min(0).max(1).default(0.5),
|
|
82
|
+
// Issue #89:v0.6.9(Issue #26)的宽容路径回归。默认跳过单条非法决策
|
|
83
|
+
// (未知 id / 跨类型合并等)、应用合法子集、run 记 degraded;设 false 恢复
|
|
84
|
+
// 整单拒绝的严格模式。全局上限与覆盖率下限不受此开关影响、始终整单拒绝。
|
|
85
|
+
dreamSkipInvalid: z.boolean().default(true),
|
|
86
|
+
// 显式开启后放宽跨类型合并检查(类型边界由用户自行承担);配合
|
|
87
|
+
// dreamSkipInvalid 理解:关闭 skipInvalid 时跨类型 merge 直接整单拒绝。
|
|
88
|
+
allowCrossTypeMerge: z.boolean().default(false),
|
|
77
89
|
// Rule version for dream adjudication: when this bumps, older dream_runs
|
|
78
90
|
// degrade to historical evidence (their receipts no longer drive live
|
|
79
91
|
// decisions). Default 0 = no versioning in use yet.
|
package/src/dream/decisions.js
CHANGED
|
@@ -7,12 +7,22 @@ const EPISTEMIC_PRIORITY = { observation: 3, inferred: 2, subjective: 1 };
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Validate a dream decision list against a snapshot of eligible memories.
|
|
10
|
-
* @param decisions - LLM-produced decision list.
|
|
10
|
+
* @param decisions - LLM-produced decision list. In skipInvalid mode, invalid
|
|
11
|
+
* entries are spliced out of this array in place (the caller reuses the same
|
|
12
|
+
* reference downstream for apply/audit); implicit keeps are appended here too.
|
|
11
13
|
* @param snapshot - Map<id, memory> of eligible (non-archived, non-summary) entries.
|
|
12
|
-
* @returns {{ok: boolean, errors: string[]}}
|
|
14
|
+
* @returns {{ok: boolean, errors: string[], skipped?: Array<{index, action, ids, error}>}}
|
|
13
15
|
*/
|
|
14
16
|
export function validateDecisions(decisions, snapshot, options = {}) {
|
|
15
17
|
const errors = [];
|
|
18
|
+
// Issue #89 回归修复(v0.6.9 / Issue #26 的 skipInvalid 路径原样移植,该路径
|
|
19
|
+
// 在 v0.7.11 重写中丢失):skipInvalid 开启时,单条非法决策只跳过该条(记录
|
|
20
|
+
// 到 skipped、不 claim 任何 id),合法子集照常应用,调用方据此把 run 记为
|
|
21
|
+
// degraded。全局信号(update/create 上限、显式覆盖率下限)依旧整单拒绝——
|
|
22
|
+
// 刷爆上限或覆盖率不达标的输出是模型坏了,不是轻微 schema 漂移。
|
|
23
|
+
const skipped = [];
|
|
24
|
+
const survivors = [];
|
|
25
|
+
const skipInvalid = options.skipInvalid === true;
|
|
16
26
|
const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
|
|
17
27
|
const minAgeHours = options.minAgeHours ?? 24;
|
|
18
28
|
if (!Array.isArray(decisions) || decisions.length === 0) {
|
|
@@ -21,106 +31,122 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
21
31
|
const claimed = new Set();
|
|
22
32
|
for (const [index, d] of decisions.entries()) {
|
|
23
33
|
const at = `decision[${index}]`;
|
|
34
|
+
// 单条校验错误先进 local:skipInvalid 模式下整条跳过,严格模式下才并入
|
|
35
|
+
// 全局 errors(沿用 v0.6.9 的双轨结构)。
|
|
36
|
+
const local = [];
|
|
37
|
+
const ids = d && d.action === "conflict" ? [d.winner, d.loser] : (d?.ids ?? []);
|
|
24
38
|
if (!d || typeof d !== "object" || !ACTIONS.has(d.action)) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
const ids = d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
|
|
29
|
-
if (d.action === "conflict") {
|
|
39
|
+
local.push(`${at}: invalid action ${JSON.stringify(d?.action)}`);
|
|
40
|
+
} else if (d.action === "conflict") {
|
|
30
41
|
if (!d.winner || !d.loser || d.winner === d.loser) {
|
|
31
|
-
|
|
32
|
-
continue;
|
|
42
|
+
local.push(`${at}: conflict needs distinct winner and loser`);
|
|
33
43
|
}
|
|
34
44
|
} else if (d.action === "create") {
|
|
35
45
|
// Mint a fresh memory (sleep pattern discovery). Claims no existing id,
|
|
36
46
|
// so it skips the claiming loop below; evidence is optional provenance
|
|
37
47
|
// (already filtered to real ids by the caller) and is stored in content.
|
|
38
48
|
if (typeof d.title !== "string" || !d.title.trim()) {
|
|
39
|
-
|
|
40
|
-
continue;
|
|
49
|
+
local.push(`${at}: create needs non-empty title`);
|
|
41
50
|
}
|
|
42
51
|
if (typeof d.content !== "string" || !d.content.trim()) {
|
|
43
|
-
|
|
44
|
-
continue;
|
|
52
|
+
local.push(`${at}: create needs non-empty content`);
|
|
45
53
|
}
|
|
46
54
|
if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
|
|
47
|
-
|
|
55
|
+
local.push(`${at}: create importance must be an integer 1-5 when provided`);
|
|
48
56
|
}
|
|
49
57
|
if (typeof d.type !== "string" || !d.type.trim()) {
|
|
50
|
-
|
|
58
|
+
local.push(`${at}: create needs non-empty type`);
|
|
51
59
|
}
|
|
52
|
-
continue;
|
|
53
60
|
} else if (!Array.isArray(d.ids) || d.ids.length === 0) {
|
|
54
|
-
|
|
55
|
-
continue;
|
|
61
|
+
local.push(`${at}: ${d.action} needs non-empty ids`);
|
|
56
62
|
}
|
|
57
63
|
// update-specific field validation runs BEFORE claiming ids, so a failing
|
|
58
64
|
// update never pollutes the claimed set (which drives the "every id must
|
|
59
65
|
// appear in a decision" check below).
|
|
60
|
-
if (d
|
|
66
|
+
if (local.length === 0 && d?.action === "update") {
|
|
61
67
|
// 只能更新单条
|
|
62
68
|
if (!Array.isArray(d.ids) || d.ids.length !== 1) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (ageHours < minAgeHours) {
|
|
83
|
-
errors.push(`${at}: memory too young (< ${minAgeHours}h)`);
|
|
84
|
-
continue;
|
|
69
|
+
local.push(`${at}: update must target exactly one id`);
|
|
70
|
+
} else {
|
|
71
|
+
// 必须产生实际变化
|
|
72
|
+
const mem = snapshot.get(d.ids[0]);
|
|
73
|
+
const hasChange = (d.title !== undefined && d.title !== mem?.title)
|
|
74
|
+
|| (d.content !== undefined && d.content !== mem?.content)
|
|
75
|
+
|| (d.importance !== undefined && d.importance !== mem?.importance);
|
|
76
|
+
if (!hasChange) {
|
|
77
|
+
local.push(`${at}: update must change at least one field`);
|
|
78
|
+
} else if (mem?.type === "summary") {
|
|
79
|
+
// 不能更新 summary
|
|
80
|
+
local.push(`${at}: cannot update summary via update action`);
|
|
81
|
+
} else {
|
|
82
|
+
// 保护期:新建记忆不可立即被 update(可配置)
|
|
83
|
+
const ageHours = (Date.now() - new Date(mem?.created_at).getTime()) / 3600000;
|
|
84
|
+
if (ageHours < minAgeHours) {
|
|
85
|
+
local.push(`${at}: memory too young (< ${minAgeHours}h)`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
85
88
|
}
|
|
86
89
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
if (local.length === 0 && d && d.action !== "create") {
|
|
91
|
+
// seen 捕获同一条决策内的重复 id;claimed 只含先前存活决策的 id
|
|
92
|
+
// (被跳过的决策不 claim,其目标留给其它合法决策/隐式 keep)。
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
for (const id of ids) {
|
|
95
|
+
const mem = snapshot.get(id);
|
|
96
|
+
if (!mem) {
|
|
97
|
+
local.push(`${at}: unknown id ${JSON.stringify(id)}`);
|
|
98
|
+
} else if (mem.archived || mem.type === "summary") {
|
|
99
|
+
local.push(`${at}: id ${JSON.stringify(id)} is archived or summary (not eligible)`);
|
|
100
|
+
}
|
|
101
|
+
if (claimed.has(id) || seen.has(id)) {
|
|
102
|
+
local.push(`${at}: id ${JSON.stringify(id)} claimed by multiple decisions`);
|
|
103
|
+
}
|
|
104
|
+
seen.add(id);
|
|
93
105
|
}
|
|
94
|
-
if (
|
|
95
|
-
|
|
106
|
+
if (local.length === 0 && d.action === "merge") {
|
|
107
|
+
if (!d.keepSource || !d.ids.includes(d.keepSource)) {
|
|
108
|
+
local.push(`${at}: merge keepSource must be one of ids`);
|
|
109
|
+
}
|
|
110
|
+
if (typeof d.title !== "string" || !d.title.trim() || typeof d.content !== "string" || !d.content.trim()) {
|
|
111
|
+
local.push(`${at}: merge needs non-empty title and content`);
|
|
112
|
+
}
|
|
113
|
+
if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
|
|
114
|
+
local.push(`${at}: merge importance must be an integer 1-5 when provided`);
|
|
115
|
+
}
|
|
116
|
+
// Merging across types would blur preference/project/decision boundaries
|
|
117
|
+
// in the injected context; the snapshot carries each entry's type.
|
|
118
|
+
// Issue #26 (P1):默认禁止跨类型合并。用户显式开启 allowCrossTypeMerge
|
|
119
|
+
// 后放宽该检查,类型边界由用户自行承担。
|
|
120
|
+
const mergeTypes = new Set(d.ids.map((id) => snapshot.get(id)?.type));
|
|
121
|
+
if (mergeTypes.size > 1 && options.allowCrossTypeMerge !== true) {
|
|
122
|
+
local.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
|
|
123
|
+
}
|
|
96
124
|
}
|
|
97
|
-
claimed.add(id);
|
|
98
125
|
}
|
|
99
|
-
if (
|
|
100
|
-
if (
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
errors.push(`${at}: merge importance must be an integer 1-5 when provided`);
|
|
108
|
-
}
|
|
109
|
-
// Merging across types would blur preference/project/decision boundaries
|
|
110
|
-
// in the injected context; the snapshot carries each entry's type.
|
|
111
|
-
const mergeTypes = new Set(d.ids.map((id) => snapshot.get(id)?.type));
|
|
112
|
-
if (mergeTypes.size > 1) {
|
|
113
|
-
errors.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
|
|
126
|
+
if (local.length > 0) {
|
|
127
|
+
if (skipInvalid) {
|
|
128
|
+
// 单条非法 → 跳过该决策,不 claim id(其目标记忆留给其它合法决策/隐式
|
|
129
|
+
// keep),并记录到 skipped 供调用方日志/审计。信息性跳过绝不写入全局
|
|
130
|
+
// errors,否则会误触发下方的整单拒绝。
|
|
131
|
+
skipped.push({ index, action: d?.action, ids, error: local.join("; ") });
|
|
132
|
+
} else {
|
|
133
|
+
errors.push(...local);
|
|
114
134
|
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (d.action !== "create") {
|
|
138
|
+
for (const id of ids) claimed.add(id);
|
|
115
139
|
}
|
|
140
|
+
survivors.push(d);
|
|
116
141
|
}
|
|
117
|
-
// Cap update churn: too many edits in one cycle signals a runaway model
|
|
118
|
-
|
|
142
|
+
// Cap update churn: too many edits in one cycle signals a runaway model.
|
|
143
|
+
// 全局信号——skipInvalid 模式下依旧整单拒绝(见函数头注释)。
|
|
144
|
+
const updateCount = survivors.filter((d) => d.action === "update").length;
|
|
119
145
|
if (updateCount > maxUpdatePerRun) {
|
|
120
146
|
errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
|
|
121
147
|
}
|
|
122
148
|
// Cap pattern minting per run (sleepMaxPatternPerRun passes through here).
|
|
123
|
-
const createCount =
|
|
149
|
+
const createCount = survivors.filter((d) => d.action === "create").length;
|
|
124
150
|
const maxCreatePerRun = options.maxCreatePerRun ?? 5;
|
|
125
151
|
if (createCount > maxCreatePerRun) {
|
|
126
152
|
errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
|
|
@@ -137,25 +163,31 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
137
163
|
// snapshot(claimed.size / snapshot.size < dreamMinExplicitCoverage)时整单拒绝,
|
|
138
164
|
// 而不是用 keep 把绝大部分 snapshot 全部"通过"。
|
|
139
165
|
if (errors.length > 0) {
|
|
140
|
-
return { ok: false, errors };
|
|
166
|
+
return { ok: false, errors, skipped };
|
|
141
167
|
}
|
|
142
168
|
const minCoverage = options.dreamMinExplicitCoverage ?? 0.5;
|
|
143
169
|
if (options.dreamImplicitKeep !== false) {
|
|
144
170
|
const coverage = snapshot.size > 0 ? claimed.size / snapshot.size : 1;
|
|
145
171
|
if (coverage < minCoverage) {
|
|
146
172
|
errors.push(`explicit decision coverage ${Math.round(coverage * 100)}% < minimum ${Math.round(minCoverage * 100)}%`);
|
|
147
|
-
return { ok: false, errors };
|
|
173
|
+
return { ok: false, errors, skipped };
|
|
148
174
|
}
|
|
149
175
|
for (const id of snapshot.keys()) {
|
|
150
|
-
if (!claimed.has(id))
|
|
176
|
+
if (!claimed.has(id)) survivors.push({ action: "keep", ids: [id] });
|
|
151
177
|
}
|
|
152
178
|
} else {
|
|
153
179
|
for (const id of snapshot.keys()) {
|
|
154
180
|
if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
|
|
155
181
|
}
|
|
156
|
-
if (errors.length > 0) return { ok: false, errors };
|
|
182
|
+
if (errors.length > 0) return { ok: false, errors, skipped };
|
|
183
|
+
}
|
|
184
|
+
// 调用方下游(apply/audit)复用同一 decisions 引用:就地同步为 survivors——
|
|
185
|
+
// 在 skipInvalid 模式下去掉被跳过的非法决策;在隐式 keep 下追加补齐的 keep。
|
|
186
|
+
// 内容一致时(无跳过、无补齐)为 no-op。
|
|
187
|
+
if (survivors.length !== decisions.length) {
|
|
188
|
+
decisions.splice(0, decisions.length, ...survivors);
|
|
157
189
|
}
|
|
158
|
-
return { ok: true, errors };
|
|
190
|
+
return { ok: true, errors, skipped };
|
|
159
191
|
}
|
|
160
192
|
|
|
161
193
|
/** Marker thrown when a decision target changed since the run snapshot. */
|
package/src/dream/sleep.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { randomUUID, createHash } from "node:crypto";
|
|
18
18
|
import { validateDecisions, applyDecisions } from "./decisions.js";
|
|
19
19
|
import { findPotentialConflicts } from "./clustering.js";
|
|
20
|
-
import { buildReceipt, withEffortFallback } from "../dream.js";
|
|
20
|
+
import { buildReceipt, describeStreamFailure, withEffortFallback } from "../dream.js";
|
|
21
21
|
import { computeHeat } from "../heat.js";
|
|
22
22
|
|
|
23
23
|
const SUMMARY_MAX = 120;
|
|
@@ -61,12 +61,17 @@ function parseJsonArray(text) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/** Same stream consumption contract as dream.js. */
|
|
64
|
-
async function streamText(ctx, options) {
|
|
64
|
+
async function streamText(ctx, options, onStreamError) {
|
|
65
65
|
if (!ctx?.llm?.stream) return undefined;
|
|
66
66
|
let text = "";
|
|
67
67
|
for await (const chunk of ctx.llm.stream(options)) {
|
|
68
68
|
if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
|
|
69
69
|
if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
|
|
70
|
+
// Same rc.1 error-as-finish-chunk behavior as dream.js — surface the
|
|
71
|
+
// cause instead of discarding it.
|
|
72
|
+
if (typeof onStreamError === "function") {
|
|
73
|
+
try { onStreamError(chunk.reason); } catch { /* diagnostics only */ }
|
|
74
|
+
}
|
|
70
75
|
return undefined;
|
|
71
76
|
}
|
|
72
77
|
}
|
|
@@ -186,7 +191,10 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
|
|
|
186
191
|
`候选冲突:\nid=${p.a.id} | type=${p.a.type} | title=${p.a.title}\n${p.a.content}\n---\nid=${p.b.id} | type=${p.b.type} | title=${p.b.title}\n${p.b.content}\n(相似度 ${p.similarity.toFixed(2)})`
|
|
187
192
|
).join("\n\n");
|
|
188
193
|
const sleepEffort = config.sleepReasoningEffort && config.sleepReasoningEffort !== "none" ? config.sleepReasoningEffort : null;
|
|
189
|
-
|
|
194
|
+
let conflictStreamFailure = "";
|
|
195
|
+
const runConflict = (withEffort) => {
|
|
196
|
+
conflictStreamFailure = "";
|
|
197
|
+
return streamText(ctx, {
|
|
190
198
|
provider: route.provider,
|
|
191
199
|
model: route.model,
|
|
192
200
|
purpose: "sleep-conflict",
|
|
@@ -196,9 +204,13 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
|
|
|
196
204
|
{ role: "system", content: [{ type: "text", text: CONFLICT_PROMPT }] },
|
|
197
205
|
{ role: "user", content: [{ type: "text", text: listText }] }
|
|
198
206
|
]
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
|
|
207
|
+
}, (reason) => { conflictStreamFailure = describeStreamFailure(reason); });
|
|
208
|
+
};
|
|
209
|
+
const text = await withEffortFallback(ctx, sleepEffort, () => runConflict(true), () => runConflict(false), () => conflictStreamFailure);
|
|
210
|
+
if (text === undefined) {
|
|
211
|
+
if (conflictStreamFailure) ctx.logger?.warn?.(`dsh-mneme sleep: conflict stream aborted or errored (${conflictStreamFailure})`);
|
|
212
|
+
return { status: "failed", error: "llm failed" };
|
|
213
|
+
}
|
|
202
214
|
const decisions = parseJsonArray(text);
|
|
203
215
|
if (!decisions) return { status: "failed", error: "invalid decisions json" };
|
|
204
216
|
// validateDecisions 要求每个 snapshot id 恰好被 claim 一次。v0.4.4 起它本身
|
|
@@ -298,7 +310,10 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
|
|
|
298
310
|
.join("\n");
|
|
299
311
|
const maxPatterns = config.sleepMaxPatternPerRun ?? 3;
|
|
300
312
|
const sleepEffort = config.sleepReasoningEffort && config.sleepReasoningEffort !== "none" ? config.sleepReasoningEffort : null;
|
|
301
|
-
|
|
313
|
+
let patternStreamFailure = "";
|
|
314
|
+
const runPattern = (withEffort) => {
|
|
315
|
+
patternStreamFailure = "";
|
|
316
|
+
return streamText(ctx, {
|
|
302
317
|
provider: route.provider,
|
|
303
318
|
model: route.model,
|
|
304
319
|
purpose: "sleep-pattern",
|
|
@@ -308,9 +323,13 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
|
|
|
308
323
|
{ role: "system", content: [{ type: "text", text: PATTERN_PROMPT.replace("N", String(maxPatterns)) }] },
|
|
309
324
|
{ role: "user", content: [{ type: "text", text: listText }] }
|
|
310
325
|
]
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
|
|
326
|
+
}, (reason) => { patternStreamFailure = describeStreamFailure(reason); });
|
|
327
|
+
};
|
|
328
|
+
const text = await withEffortFallback(ctx, sleepEffort, () => runPattern(true), () => runPattern(false), () => patternStreamFailure);
|
|
329
|
+
if (text === undefined) {
|
|
330
|
+
if (patternStreamFailure) ctx.logger?.warn?.(`dsh-mneme sleep: pattern stream aborted or errored (${patternStreamFailure})`);
|
|
331
|
+
return { status: "failed", error: "llm failed" };
|
|
332
|
+
}
|
|
314
333
|
const decisions = parseJsonArray(text);
|
|
315
334
|
if (!decisions || decisions.length === 0) return { status: "skipped", reason: "no patterns found" };
|
|
316
335
|
// Evidence ids are provenance refs; an LLM-fabricated id would mint a dead
|