Skip to content

Commit 171cfa2

Browse files
committed
feat(dream): 错峰队列镜像到巩固(#239 第 4 项)
`summarizePeakHours` 的错峰目前只覆盖蒸馏,巩固(autoDream)没有任何时间窗——而它是 另一个 LLM 大户:单次 run 的输入是整窗快照(dreamMaxSnapshotSize 条),一次调用可达 分钟级,且由写入事件触发、没有天然的「等到空闲再跑」路径。 - 新增 `dreamPeakHours` / `dreamPeakMaxDeferMinutes`:与蒸馏侧**同一份时段语法与解析** (直接复用 src/summarize.js 已导出的 parsePeakSpec / isInPeakWindow / nextOffPeakAt, 不另写解析器——两份实现漂移会让同一个时段串在两处行为不同,那比没有这个功能更糟)。 空串 = 关闭,行为与现状逐字节一致。 - 命中高峰:不调 LLM、**baseline 不刷新**(阈值继续累积,留到非高峰一次性巩固——一次 大 run 比多次小 run 省),登记一行 status='skipped' / error_message='peak-hours' 审计(沿用第 4 项口径:skip 原因必须对用户可观测),并顺延到最近的「高峰结束」时刻 补跑;被 peakMaxDeferMinutes 截断后到点仍处高峰则放行,避免长高峰把巩固饿死。 - 与蒸馏的形态差异:巩固是**全局单实例**,所以只需要一个 deferTimer,不需要 deferredRuns 那套按会话去重;顺延期间新的写入触发不叠加定时器、不重复刷审计行。 - 时钟与定时器可注入(now / setTimeoutFn / clearTimeoutFn,同 dream/sleep.js 的房型): 排程不绑死真实时钟,「高峰顺延 → 非高峰补跑」才能被确定性覆盖,也不会在 CI 上留下 真实等待。为此把开跑路径抽成 startRun(),正常触发与顺延补跑共用同一条收尾逻辑。 - 面板:巩固侧时段输入挂在 autoDream 子块内(开关关掉时不该还留着可编辑的输入框); 顺带把此前只有后端白名单、面板调不到的 `summarizePeakHours` 一并渲染——两个错峰键 一个能调一个不能,比都不给更让人困惑。双语文案齐。 - 白名单与文档:config.js schema + settings.js 成对注册(strings + int ranges)、 test/api.test.js 旗标计数锁 +2、README 两个键的文档行。 - 回归 7 条(test/dream-peak-hours.test.js):命中高峰不调模型且写审计、顺延到点补跑、 重复触发不叠加、上限截断放行、非法时段串按未配置处理(宁可不省也不误停)、非高峰 行为不变、dispose 清掉顺延定时器。 - 全量 npm test:1391 条(基线 1384 + 7),失败集合与上游 main **逐条一致**(9 条 runtime-verify 的环境相关用例,对照实验见 PR)。
1 parent 38fdbfa commit 171cfa2

12 files changed

Lines changed: 512 additions & 78 deletions

File tree

‎dsh-mneme/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,8 @@ dsh web
348348
| `dreamSummaryProvider` / `dreamSummaryModel` | 空 | 总览(dream_summarize)专用模型路由(留空 = 沿用 `dreamProvider`/`dreamModel`)。consolidate 有窗口(`dreamMaxSnapshotSize`)而总览输入随库增长,ctx 需求差数倍——用小 ctx 模型跑巩固时把总览指到大 ctx 模型(issue #258) |
349349
| `dreamSummaryMaxInputs` | `0` | 总览输入条数硬上限(0 = 不设上限):超过时按 `updated_at` 倒序只保留最新 N 条进总览,防小 ctx 模型被全库输入撑爆;总览口径脚注的条数随实际输入变化 |
350350
| `dreamMinIntervalMinutes` | `0` | autoDream 最小触发间隔(0-10080,0=不限):失败/degraded run 也占用 |
351+
| `dreamPeakHours` | 空 | 巩固侧高峰时段(本地时间,与 `summarizePeakHours` **同一份语法**):空=关;命中时不调模型、baseline 不刷新(阈值继续累积,留到非高峰一次性巩固)、登记 `skipped`/`peak-hours` 审计并顺延到最近的高峰结束时刻。适合「白天要留算力给交互、巩固挪到夜里」的场景(#239 第 4 项镜像到巩固) |
352+
| `dreamPeakMaxDeferMinutes` | `120` | 巩固侧高峰顺延上限(0-1440 分钟,0=不设上限):到点仍处高峰就照常跑,避免长高峰把巩固饿死(#239) |
351353
| `dreamNarrativeEnabled` | `false` | 叙述条总开关(#164 对齐,v0.8.4):按共享 tag 主题簇合成叙述 + evidence 证据链,注入候选排除(按需检索,常驻位只留 dream 总览);也走 feature_flags 白名单,lightMode 强制关 |
352354
| `dreamNarrativeMinCluster` | `3` | 主题簇合成叙述的最小成员数(2-20,v0.8.4) |
353355
| `apiToken` | 空 | 可选 API 鉴权 token;设置后写操作与密钥接口要求 `Authorization: Bearer <apiToken>` |

‎dsh-mneme/lib/client.js‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,10 @@ window.__ModuleLoader__.load({
458458
"memory.features.dreamProvider": "巩固模型 Provider",
459459
"memory.features.dreamModel": "巩固用模型名",
460460
"memory.features.dreamModelHint": "留空 = 跟随主对话模型;只影响记忆巩固(autoDream)用的模型",
461+
"memory.features.dreamPeakHours": "高峰时段(不做梦)",
462+
"memory.features.dreamPeakHours.hint": "空 = 关闭。逗号分隔、可带星期前缀、支持跨零点,如 09:00-18:00 或 mon-fri 08:00-12:00,14:00-18:00。命中时段不调模型,顺延到最近的高峰结束时刻补跑(最多顺延 dreamPeakMaxDeferMinutes 分钟,避免长高峰把巩固饿死)",
463+
"memory.features.summarizePeakHours": "高峰时段(不蒸馏)",
464+
"memory.features.summarizePeakHours.hint": "空 = 关闭。与上方巩固侧同一份时段语法;命中时蒸馏顺延到非高峰、窗口累积后一次蒸",
461465
"memory.features.sleepProvider": "睡眠 Provider",
462466
"memory.features.sleepModel": "睡眠模型",
463467
"memory.features.sleepModelHint": "留空 = 用巩固模型或当前模型;建议选非思考模型",
@@ -848,6 +852,10 @@ window.__ModuleLoader__.load({
848852
"memory.features.dreamProvider": "Consolidation provider",
849853
"memory.features.dreamModel": "Consolidation model",
850854
"memory.features.dreamModelHint": "Leave empty to follow the main conversation model; only affects autoDream consolidation",
855+
"memory.features.dreamPeakHours": "Peak hours (no dreaming)",
856+
"memory.features.dreamPeakHours.hint": "Empty = off. Comma-separated windows, optional weekday prefix, midnight-crossing allowed — e.g. 09:00-18:00 or mon-fri 08:00-12:00,14:00-18:00. Inside these windows no LLM call is made; the run is deferred to the nearest peak-end (capped by dreamPeakMaxDeferMinutes so an all-day peak cannot starve consolidation)",
857+
"memory.features.summarizePeakHours": "Peak hours (no distillation)",
858+
"memory.features.summarizePeakHours.hint": "Empty = off. Same window syntax as the consolidation side above; inside a peak, distillation is deferred and the window accumulates for one bigger run",
851859
"memory.features.sleepProvider": "Sleep provider",
852860
"memory.features.sleepModel": "Sleep model",
853861
"memory.features.sleepModelHint": "Leave empty to reuse the consolidation model; a non-reasoning model is recommended",
@@ -1946,7 +1954,10 @@ window.__ModuleLoader__.load({
19461954
const FEATURE_ADVANCED_BOOLS = ["hybridInject", "selectiveInjectEnabled", "adaptiveThresholdEnabled", "reflectionUpdateEnabled", "reflectionFailureTracking", "conflictFreezeEnabled", "trustEpistemicWeighting"];
19471955
// 字符串键(blur/Enter 提交,空串合法 = 跟随默认):巩固模型与语义
19481956
// 检索路线。embedProvider 是枚举,用下拉单独渲染。
1949-
const FEATURE_STRINGS = ["dreamProvider", "dreamModel", "sleepProvider", "sleepModel", "entityExtractionProvider", "entityExtractionModel", "localEmbedModel", "ollamaBaseUrl", "ollamaModel"];
1957+
const FEATURE_STRINGS = ["dreamProvider", "dreamModel", "sleepProvider", "sleepModel", "entityExtractionProvider", "entityExtractionModel", "localEmbedModel", "ollamaBaseUrl", "ollamaModel",
1958+
// Issue #239 第 4 项:错峰时段串(巩固侧与蒸馏侧)。此前只有后端白名单、
1959+
// 面板调不到——两个错峰键一个能调一个不能比都不给更让人困惑。
1960+
"dreamPeakHours", "summarizePeakHours"];
19501961
const EMBED_PROVIDERS = ["openai", "local", "ollama"];
19511962
// 实体抽取思考强度(issue #109):与后端 FEATURE_FLAG_ENUMS 枚举对齐。
19521963
const ENTITY_REASONING = ["none", "low", "medium", "high"];
@@ -2199,13 +2210,24 @@ window.__ModuleLoader__.load({
21992210
eff.embedProvider === "ollama" && strRow("ollamaModel")
22002211
);
22012212

2213+
// Issue #239 第 4 项:蒸馏侧错峰时段串(空 = 关闭)。挂在 autoSummarize
2214+
// 下面——与巩固侧对称,两处错峰开关都能在面板上调。
2215+
const summarizeSub = eff.autoSummarize && h("div", { className: "mneme-featsub" },
2216+
strRow("summarizePeakHours"),
2217+
h("div", { className: "mneme-featsubhint" }, t("memory.features.summarizePeakHours.hint"))
2218+
);
2219+
22022220
// 巩固模型:autoDream 开着才展开,避免闲置配置占版面。/llm-providers
22032221
// 可用时用级联下拉 + 连通性测试;旧后端(端点 404)回退纯文本输入。
22042222
const dreamSub = eff.autoDream && h("div", { className: "mneme-featsub" },
22052223
Array.isArray(routes)
22062224
? routeSelects("dreamProvider", "dreamModel", dreamTest, setDreamTest, "dreamReasoningEffort")
22072225
: h(react.Fragment, null, strRow("dreamProvider"), strRow("dreamModel")),
2208-
h("div", { className: "mneme-featsubhint" }, t("memory.features.dreamModelHint"))
2226+
h("div", { className: "mneme-featsubhint" }, t("memory.features.dreamModelHint")),
2227+
// Issue #239 第 4 项镜像到巩固:高峰时段串(空 = 关闭)。放在 autoDream
2228+
// 子块内——它是巩固的排程,开关关掉时不该还在界面上留着可编辑的输入框。
2229+
strRow("dreamPeakHours"),
2230+
h("div", { className: "mneme-featsubhint" }, t("memory.features.dreamPeakHours.hint"))
22092231
);
22102232

22112233
// 睡眠模型:sleepModeEnabled 开着才展开(sleepProvider/sleepModel 随本版
@@ -2252,6 +2274,7 @@ window.__ModuleLoader__.load({
22522274
FEATURE_GROUPS.map((g) => h(react.Fragment, { key: g.key },
22532275
h("div", { className: "mneme-featgroup" }, t(`memory.features.${g.key}`)),
22542276
g.items.map(flagRow),
2277+
g.key === "group.core" && h(react.Fragment, null, summarizeSub),
22552278
g.key === "group.enhance" && h(react.Fragment, null, embedSub, entitySub),
22562279
g.key === "group.dream" && h(react.Fragment, null, dreamSub, sleepSub)
22572280
)),

‎dsh-mneme/lib/config.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,22 @@ export const Config = z.object({
145145
// 起算,失败/degraded 的 run 也占用间隔;间隔内的触发请求静默跳过,下一次
146146
// 写入事件会重新评估。
147147
dreamMinIntervalMinutes: z.natural().min(0).max(10080).default(0),
148+
// Issue #239(第 4 项,错峰队列)镜像到巩固:高峰期不做梦。与
149+
// summarizePeakHours 同一份时段语法(复用 src/summarize.js 的 parsePeakSpec /
150+
// isInPeakWindow / nextOffPeakAt,不另写解析器):逗号分隔、可带星期前缀、支持
151+
// 跨零点。空串 = 关闭,行为与现状逐字节一致。
152+
// "09:00-18:00" 每天 09:00-18:00
153+
// "mon-fri 08:00-12:00,14:00-18:00" 工作日两段(按高峰计费的供应商即此形态)
154+
// 为什么巩固比蒸馏更该有这道闸:单次巩固的输入是整窗快照(dreamMaxSnapshotSize
155+
// 条),实测一次 run 的 LLM 时长可达数分钟量级,撞上高峰时既贵又慢;而它由写入
156+
// 事件触发、没有天然的「等到空闲再跑」路径。命中高峰时:不调 LLM、不刷新
157+
// baseline(阈值继续累积,留到非高峰一次性巩固),登记一行 status='skipped' /
158+
// error_message='peak-hours' 审计,并按下面的上限择时补跑。任一写法非法则整串
159+
// 按「未配置」处理——排程是省钱手段,绝不该因为写错格式把巩固停掉。
160+
dreamPeakHours: z.string().default(""),
161+
// 高峰顺延上限(分钟,0 = 不设上限):到点仍处高峰就照常跑,避免整天高峰把巩固
162+
// 饿死。默认 120,与 summarizePeakMaxDeferMinutes 对齐。仅在时段串非空时生效。
163+
dreamPeakMaxDeferMinutes: z.natural().min(0).max(1440).default(120),
148164
// 巩固模型路由(settings panel「巩固模型」/ dreamProvider+dreamModel):
149165
// dream 的记忆沉淀专用 LLM 路由,显式配置优先于 agent 默认模型(config-first,
150166
// Issue #25)。模型分类声明:

‎dsh-mneme/lib/dream.js‎

Lines changed: 97 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { validateDecisions, applyDecisions } from "./dream/decisions.js";
22
import { clusterMemories, findPotentialConflicts, cosineSimilarity } from "./dream/clustering.js";
33
import { clusterByTag, intersectEvidence } from "./dream/narratives.js";
44
import { scopeKeyOf } from "./scope.js";
5+
// Issue #239(第 4 项)镜像到巩固:错峰时段解析与「最近的高峰结束时刻」直接复用
6+
// 蒸馏侧已导出的纯函数,不另写一份解析器——两份实现漂移会让「同一个时段串在两处
7+
// 行为不同」,那比没有这个功能更糟。summarize.js 只依赖 dsh-llm 与 lang.js,
8+
// 不反向依赖 dream.js,无循环引用。
9+
import { isInPeakWindow, nextOffPeakAt } from "./summarize.js";
510
import { createHash, randomUUID } from "node:crypto";
611
import { STR, langOf } from "./lang.js";
712
export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure, resolveDreamEffort, resolveRoute };
@@ -744,8 +749,12 @@ export async function maintainIndexAfterDream(decisions, service, semantic) {
744749
if (embedder.modelHash) vectorIndex.markModel?.(embedder.modelHash, embedder.dimension);
745750
}
746751

747-
export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, logger, semantic = null, lastRunAtSeed = 0 }) {
752+
export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, logger, semantic = null, lastRunAtSeed = 0, peakHours = "", peakMaxDeferMinutes = 120, auditPeakSkip = null, now = () => Date.now(), setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout }) {
748753
let pendingTimer = null;
754+
// Issue #239(第 4 项)镜像到巩固:高峰顺延定时器。与 pendingTimer 分开——两者
755+
// 语义不同(一个是「马上要跑」,一个是「等出高峰再跑」),合成一个变量会让
756+
// maybeSchedule 的守卫在顺延期间把新的写入触发误当成「已有待跑」而吞掉。
757+
let deferTimer = null;
749758
let running = false;
750759
let disposed = false;
751760
let baseline = { count: 0, chars: 0 };
@@ -766,51 +775,103 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
766775
}
767776

768777
function maybeSchedule(service) {
769-
if (disposed || running || pendingTimer) return false;
778+
if (disposed || running || pendingTimer || deferTimer) return false;
770779
// Issue #89(请求 2):最小触发间隔闸门。
771-
if (minIntervalMs > 0 && Date.now() - lastRunAt < minIntervalMs) return false;
780+
if (minIntervalMs > 0 && now() - lastRunAt < minIntervalMs) return false;
772781
const { trigger, count, chars } = shouldTrigger(service);
773782
if (!trigger) return false;
774-
pendingTimer = setTimeout(() => {
783+
// Issue #239(第 4 项,错峰队列)镜像到巩固:命中高峰就不调模型。与蒸馏的差别
784+
// 在于巩固是**全局单实例**(蒸馏按会话各挂一个定时器),所以这里只需要一个
785+
// deferTimer,且不需要 deferredRuns 那套按会话去重。
786+
// baseline 刻意不刷新:阈值继续累积,留到非高峰一次性巩固(一次大 run 比多次
787+
// 小 run 省)。审计只登记一行 skip——「为什么不再做梦了」必须对用户可观测。
788+
if (isInPeakWindow(new Date(now()), peakHours)) {
789+
try {
790+
auditPeakSkip?.({ count, chars });
791+
} catch (error) {
792+
// 记账是 best-effort:写审计行失败只 warn,绝不反噬调度本身。
793+
logger?.warn?.(`dsh-mneme dream: peak-hours audit failed: ${String(error)}`);
794+
}
795+
scheduleDeferredRun(service);
796+
return false;
797+
}
798+
pendingTimer = setTimeoutFn(() => {
775799
pendingTimer = null;
776-
running = true;
777-
lastRunAt = Date.now();
778-
// Defer the onRun invocation so a synchronous throw cannot escape the
779-
// timer callback (which would crash the process) and skip the teardown.
780-
// Errors are logged, never swallowed silently. inFlight lets dispose()
781-
// await the running consolidation before the caller closes the store.
782-
inFlight = Promise.resolve()
783-
.then(() => (onRun ? onRun() : Promise.resolve({ ok: true, skipped: true })))
784-
.then((result) => {
785-
// Refresh the baseline only for a successful run (design §5.3: an
786-
// LLM failure must not move the baseline, so the next write can
787-
// immediately re-trigger a retry). A `{ok:false}` result or a throw
788-
// keeps the old baseline. A run that reports nothing is treated as
789-
// completed without failure (no-op hooks / minimal test doubles).
790-
if (result && result.ok) {
791-
try {
792-
baseline = shouldTrigger(service);
793-
} catch (error) {
794-
// Store closed mid-flight: keep the last known baseline.
795-
logger?.warn?.(`dsh-mneme dream: baseline refresh failed: ${String(error)}`);
796-
}
797-
}
798-
})
799-
.catch((error) => {
800-
logger?.warn?.(`dsh-mneme dream: run failed: ${error?.message ?? error}`);
801-
// Failed runs do not refresh the baseline.
802-
})
803-
.finally(() => {
804-
running = false;
805-
inFlight = null;
806-
});
800+
startRun(service);
807801
}, delayMs);
808802
return true;
809803
}
810804

805+
/**
806+
* Issue #239:高峰内择时补跑——挂到「距当前最近的一个高峰结束时刻」,被
807+
* peakMaxDeferMinutes 截断时到点照跑(bypassPeak),长高峰不会把巩固饿死。
808+
* 定时器 unref:不阻止宿主退出。重复触发不叠加(deferTimer 已在 maybeSchedule
809+
* 的守卫里,这里再判一次以防从其它路径进来)。
810+
*/
811+
function scheduleDeferredRun(service) {
812+
if (disposed || deferTimer) return;
813+
const at = nextOffPeakAt(new Date(now()), peakHours);
814+
if (!at) return;
815+
const maxDeferMs = (peakMaxDeferMinutes ?? 0) * 60000;
816+
let delay = Math.max(0, at.getTime() - now());
817+
const capped = maxDeferMs > 0 && delay > maxDeferMs;
818+
if (capped) delay = maxDeferMs;
819+
deferTimer = setTimeoutFn(() => {
820+
deferTimer = null;
821+
if (disposed) return;
822+
// 截断放行时仍在高峰:不再重新顺延(否则长高峰里会无限顺延,等于把巩固
823+
// 关掉)。直接开跑,与蒸馏的 bypassPeak 同口径。
824+
if (!capped && isInPeakWindow(new Date(now()), peakHours)) {
825+
// 理论上到点已出高峰;时钟跳变/时段串被改小可能落回高峰内,此时再顺延一次。
826+
scheduleDeferredRun(service);
827+
return;
828+
}
829+
logger?.info?.(`dsh-mneme dream: peak-hours deferred run firing (capped=${capped}, delayMs=${delay})`);
830+
startRun(service);
831+
}, delay);
832+
deferTimer.unref?.();
833+
}
834+
835+
/**
836+
* 真正开跑。抽出来是因为两条路径都要用:写入触发的正常路径,与高峰顺延后的
837+
* 补跑路径。onRun 的调用刻意放在 Promise 里——同步抛出的异常若逃出 timer 回调
838+
* 会直接崩掉进程并跳过收尾。inFlight 让 dispose() 能等完这一轮再关库。
839+
*/
840+
function startRun(service) {
841+
running = true;
842+
lastRunAt = now();
843+
inFlight = Promise.resolve()
844+
.then(() => (onRun ? onRun() : Promise.resolve({ ok: true, skipped: true })))
845+
.then((result) => {
846+
// Refresh the baseline only for a successful run (design §5.3: an
847+
// LLM failure must not move the baseline, so the next write can
848+
// immediately re-trigger a retry). A `{ok:false}` result or a throw
849+
// keeps the old baseline. A run that reports nothing is treated as
850+
// completed without failure (no-op hooks / minimal test doubles).
851+
if (result && result.ok) {
852+
try {
853+
baseline = shouldTrigger(service);
854+
} catch (error) {
855+
// Store closed mid-flight: keep the last known baseline.
856+
logger?.warn?.(`dsh-mneme dream: baseline refresh failed: ${String(error)}`);
857+
}
858+
}
859+
})
860+
.catch((error) => {
861+
logger?.warn?.(`dsh-mneme dream: run failed: ${error?.message ?? error}`);
862+
// Failed runs do not refresh the baseline.
863+
})
864+
.finally(() => {
865+
running = false;
866+
inFlight = null;
867+
});
868+
}
869+
811870
async function dispose() {
812871
disposed = true;
813-
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
872+
if (pendingTimer) { clearTimeoutFn(pendingTimer); pendingTimer = null; }
873+
// Issue #239:高峰顺延定时器同样要清,否则进程关闭后仍会触发一次巩固。
874+
if (deferTimer) { clearTimeoutFn(deferTimer); deferTimer = null; }
814875
// An in-flight run is left to complete naturally (its LLM calls are
815876
// already paid for and aborting would discard the work). Await it so the
816877
// caller can close the store only after every write has landed.

0 commit comments

Comments
 (0)