From 26b5619385cb7350130430844ce2c90f6fc68a4a Mon Sep 17 00:00:00 2001 From: xain <233244+xain@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:23:31 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(dream):=20=E9=94=99=E5=B3=B0=E9=98=9F?= =?UTF-8?q?=E5=88=97=E9=95=9C=E5=83=8F=E5=88=B0=E5=B7=A9=E5=9B=BA=EF=BC=88?= =?UTF-8?q?#239=20=E7=AC=AC=204=20=E9=A1=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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)。 --- dsh-mneme/README.md | 2 + dsh-mneme/lib/client.js | 26 +++- dsh-mneme/lib/config.js | 16 ++ dsh-mneme/lib/dream.js | 133 +++++++++++----- dsh-mneme/lib/index.js | 26 ++++ dsh-mneme/lib/settings.js | 6 +- dsh-mneme/src/config.js | 16 ++ dsh-mneme/src/dream.js | 133 +++++++++++----- dsh-mneme/src/index.js | 26 ++++ dsh-mneme/src/settings.js | 6 +- dsh-mneme/test/api.test.js | 5 +- dsh-mneme/test/dream-peak-hours.test.js | 194 ++++++++++++++++++++++++ 12 files changed, 511 insertions(+), 78 deletions(-) create mode 100644 dsh-mneme/test/dream-peak-hours.test.js diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index 0f0f42ee..9e8c656c 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -347,6 +347,8 @@ dsh web | `dreamSummaryProvider` / `dreamSummaryModel` | 空 | 总览(dream_summarize)专用模型路由(留空 = 沿用 `dreamProvider`/`dreamModel`)。consolidate 有窗口(`dreamMaxSnapshotSize`)而总览输入随库增长,ctx 需求差数倍——用小 ctx 模型跑巩固时把总览指到大 ctx 模型(issue #258) | | `dreamSummaryMaxInputs` | `0` | 总览输入条数硬上限(0 = 不设上限):超过时按 `updated_at` 倒序只保留最新 N 条进总览,防小 ctx 模型被全库输入撑爆;总览口径脚注的条数随实际输入变化 | | `dreamMinIntervalMinutes` | `0` | autoDream 最小触发间隔(0-10080,0=不限):失败/degraded run 也占用 | +| `dreamPeakHours` | 空 | 巩固侧高峰时段(本地时间,与 `summarizePeakHours` **同一份语法**):空=关;命中时不调模型、baseline 不刷新(阈值继续累积,留到非高峰一次性巩固)、登记 `skipped`/`peak-hours` 审计并顺延到最近的高峰结束时刻。适合「白天要留算力给交互、巩固挪到夜里」的场景(#239 第 4 项镜像到巩固) | +| `dreamPeakMaxDeferMinutes` | `120` | 巩固侧高峰顺延上限(0-1440 分钟,0=不设上限):到点仍处高峰就照常跑,避免长高峰把巩固饿死(#239) | | `dreamNarrativeEnabled` | `false` | 叙述条总开关(#164 对齐,v0.8.4):按共享 tag 主题簇合成叙述 + evidence 证据链,注入候选排除(按需检索,常驻位只留 dream 总览);也走 feature_flags 白名单,lightMode 强制关 | | `dreamNarrativeMinCluster` | `3` | 主题簇合成叙述的最小成员数(2-20,v0.8.4) | | `apiToken` | 空 | 可选 API 鉴权 token;设置后写操作与密钥接口要求 `Authorization: Bearer ` | diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js index 4bcf03ff..0db12535 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -465,6 +465,10 @@ window.__ModuleLoader__.load({ "memory.features.dreamProvider": "巩固模型 Provider", "memory.features.dreamModel": "巩固用模型名", "memory.features.dreamModelHint": "留空 = 跟随主对话模型;只影响记忆巩固(autoDream)用的模型", + "memory.features.dreamPeakHours": "高峰时段(不做梦)", + "memory.features.dreamPeakHours.hint": "空 = 关闭。逗号分隔、可带星期前缀、支持跨零点,如 09:00-18:00 或 mon-fri 08:00-12:00,14:00-18:00。命中时段不调模型,顺延到最近的高峰结束时刻补跑(最多顺延 dreamPeakMaxDeferMinutes 分钟,避免长高峰把巩固饿死)", + "memory.features.summarizePeakHours": "高峰时段(不蒸馏)", + "memory.features.summarizePeakHours.hint": "空 = 关闭。与上方巩固侧同一份时段语法;命中时蒸馏顺延到非高峰、窗口累积后一次蒸", "memory.features.sleepProvider": "睡眠 Provider", "memory.features.sleepModel": "睡眠模型", "memory.features.sleepModelHint": "留空 = 用巩固模型或当前模型;建议选非思考模型", @@ -862,6 +866,10 @@ window.__ModuleLoader__.load({ "memory.features.dreamProvider": "Consolidation provider", "memory.features.dreamModel": "Consolidation model", "memory.features.dreamModelHint": "Leave empty to follow the main conversation model; only affects autoDream consolidation", + "memory.features.dreamPeakHours": "Peak hours (no dreaming)", + "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)", + "memory.features.summarizePeakHours": "Peak hours (no distillation)", + "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", "memory.features.sleepProvider": "Sleep provider", "memory.features.sleepModel": "Sleep model", "memory.features.sleepModelHint": "Leave empty to reuse the consolidation model; a non-reasoning model is recommended", @@ -1960,7 +1968,10 @@ window.__ModuleLoader__.load({ const FEATURE_ADVANCED_BOOLS = ["hybridInject", "selectiveInjectEnabled", "adaptiveThresholdEnabled", "reflectionUpdateEnabled", "reflectionFailureTracking", "conflictFreezeEnabled", "trustEpistemicWeighting"]; // 字符串键(blur/Enter 提交,空串合法 = 跟随默认):巩固模型与语义 // 检索路线。embedProvider 是枚举,用下拉单独渲染。 - const FEATURE_STRINGS = ["dreamProvider", "dreamModel", "sleepProvider", "sleepModel", "entityExtractionProvider", "entityExtractionModel", "localEmbedModel", "ollamaBaseUrl", "ollamaModel"]; + const FEATURE_STRINGS = ["dreamProvider", "dreamModel", "sleepProvider", "sleepModel", "entityExtractionProvider", "entityExtractionModel", "localEmbedModel", "ollamaBaseUrl", "ollamaModel", + // Issue #239 第 4 项:错峰时段串(巩固侧与蒸馏侧)。此前只有后端白名单、 + // 面板调不到——两个错峰键一个能调一个不能比都不给更让人困惑。 + "dreamPeakHours", "summarizePeakHours"]; const EMBED_PROVIDERS = ["openai", "local", "ollama"]; // 实体抽取思考强度(issue #109):与后端 FEATURE_FLAG_ENUMS 枚举对齐。 const ENTITY_REASONING = ["none", "low", "medium", "high"]; @@ -2216,13 +2227,24 @@ window.__ModuleLoader__.load({ eff.embedProvider === "ollama" && strRow("ollamaModel") ); + // Issue #239 第 4 项:蒸馏侧错峰时段串(空 = 关闭)。挂在 autoSummarize + // 下面——与巩固侧对称,两处错峰开关都能在面板上调。 + const summarizeSub = eff.autoSummarize && h("div", { className: "mneme-featsub" }, + strRow("summarizePeakHours"), + h("div", { className: "mneme-featsubhint" }, t("memory.features.summarizePeakHours.hint")) + ); + // 巩固模型:autoDream 开着才展开,避免闲置配置占版面。/llm-providers // 可用时用级联下拉 + 连通性测试;旧后端(端点 404)回退纯文本输入。 const dreamSub = eff.autoDream && h("div", { className: "mneme-featsub" }, Array.isArray(routes) ? routeSelects("dreamProvider", "dreamModel", dreamTest, setDreamTest, "dreamReasoningEffort") : h(react.Fragment, null, strRow("dreamProvider"), strRow("dreamModel")), - h("div", { className: "mneme-featsubhint" }, t("memory.features.dreamModelHint")) + h("div", { className: "mneme-featsubhint" }, t("memory.features.dreamModelHint")), + // Issue #239 第 4 项镜像到巩固:高峰时段串(空 = 关闭)。放在 autoDream + // 子块内——它是巩固的排程,开关关掉时不该还在界面上留着可编辑的输入框。 + strRow("dreamPeakHours"), + h("div", { className: "mneme-featsubhint" }, t("memory.features.dreamPeakHours.hint")) ); // 睡眠模型:sleepModeEnabled 开着才展开(sleepProvider/sleepModel 随本版 diff --git a/dsh-mneme/lib/config.js b/dsh-mneme/lib/config.js index c62c1784..4c68f9a1 100644 --- a/dsh-mneme/lib/config.js +++ b/dsh-mneme/lib/config.js @@ -145,6 +145,22 @@ export const Config = z.object({ // 起算,失败/degraded 的 run 也占用间隔;间隔内的触发请求静默跳过,下一次 // 写入事件会重新评估。 dreamMinIntervalMinutes: z.natural().min(0).max(10080).default(0), + // Issue #239(第 4 项,错峰队列)镜像到巩固:高峰期不做梦。与 + // summarizePeakHours 同一份时段语法(复用 src/summarize.js 的 parsePeakSpec / + // isInPeakWindow / nextOffPeakAt,不另写解析器):逗号分隔、可带星期前缀、支持 + // 跨零点。空串 = 关闭,行为与现状逐字节一致。 + // "09:00-18:00" 每天 09:00-18:00 + // "mon-fri 08:00-12:00,14:00-18:00" 工作日两段(按高峰计费的供应商即此形态) + // 为什么巩固比蒸馏更该有这道闸:单次巩固的输入是整窗快照(dreamMaxSnapshotSize + // 条),实测一次 run 的 LLM 时长可达数分钟量级,撞上高峰时既贵又慢;而它由写入 + // 事件触发、没有天然的「等到空闲再跑」路径。命中高峰时:不调 LLM、不刷新 + // baseline(阈值继续累积,留到非高峰一次性巩固),登记一行 status='skipped' / + // error_message='peak-hours' 审计,并按下面的上限择时补跑。任一写法非法则整串 + // 按「未配置」处理——排程是省钱手段,绝不该因为写错格式把巩固停掉。 + dreamPeakHours: z.string().default(""), + // 高峰顺延上限(分钟,0 = 不设上限):到点仍处高峰就照常跑,避免整天高峰把巩固 + // 饿死。默认 120,与 summarizePeakMaxDeferMinutes 对齐。仅在时段串非空时生效。 + dreamPeakMaxDeferMinutes: z.natural().min(0).max(1440).default(120), // 巩固模型路由(settings panel「巩固模型」/ dreamProvider+dreamModel): // dream 的记忆沉淀专用 LLM 路由,显式配置优先于 agent 默认模型(config-first, // Issue #25)。模型分类声明: diff --git a/dsh-mneme/lib/dream.js b/dsh-mneme/lib/dream.js index 3a34c6b5..d3d472b1 100644 --- a/dsh-mneme/lib/dream.js +++ b/dsh-mneme/lib/dream.js @@ -2,6 +2,11 @@ import { validateDecisions, applyDecisions } from "./dream/decisions.js"; import { clusterMemories, findPotentialConflicts, cosineSimilarity } from "./dream/clustering.js"; import { clusterByTag, intersectEvidence } from "./dream/narratives.js"; import { scopeKeyOf } from "./scope.js"; +// Issue #239(第 4 项)镜像到巩固:错峰时段解析与「最近的高峰结束时刻」直接复用 +// 蒸馏侧已导出的纯函数,不另写一份解析器——两份实现漂移会让「同一个时段串在两处 +// 行为不同」,那比没有这个功能更糟。summarize.js 只依赖 dsh-llm 与 lang.js, +// 不反向依赖 dream.js,无循环引用。 +import { isInPeakWindow, nextOffPeakAt } from "./summarize.js"; import { createHash, randomUUID } from "node:crypto"; import { STR, langOf } from "./lang.js"; export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure, resolveDreamEffort, resolveRoute }; @@ -750,8 +755,12 @@ export async function maintainIndexAfterDream(decisions, service, semantic) { if (embedder.modelHash) vectorIndex.markModel?.(embedder.modelHash, embedder.dimension); } -export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, logger, semantic = null, lastRunAtSeed = 0 }) { +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 }) { let pendingTimer = null; + // Issue #239(第 4 项)镜像到巩固:高峰顺延定时器。与 pendingTimer 分开——两者 + // 语义不同(一个是「马上要跑」,一个是「等出高峰再跑」),合成一个变量会让 + // maybeSchedule 的守卫在顺延期间把新的写入触发误当成「已有待跑」而吞掉。 + let deferTimer = null; let running = false; let disposed = false; let baseline = { count: 0, chars: 0 }; @@ -772,51 +781,103 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar } function maybeSchedule(service) { - if (disposed || running || pendingTimer) return false; + if (disposed || running || pendingTimer || deferTimer) return false; // Issue #89(请求 2):最小触发间隔闸门。 - if (minIntervalMs > 0 && Date.now() - lastRunAt < minIntervalMs) return false; + if (minIntervalMs > 0 && now() - lastRunAt < minIntervalMs) return false; const { trigger, count, chars } = shouldTrigger(service); if (!trigger) return false; - pendingTimer = setTimeout(() => { + // Issue #239(第 4 项,错峰队列)镜像到巩固:命中高峰就不调模型。与蒸馏的差别 + // 在于巩固是**全局单实例**(蒸馏按会话各挂一个定时器),所以这里只需要一个 + // deferTimer,且不需要 deferredRuns 那套按会话去重。 + // baseline 刻意不刷新:阈值继续累积,留到非高峰一次性巩固(一次大 run 比多次 + // 小 run 省)。审计只登记一行 skip——「为什么不再做梦了」必须对用户可观测。 + if (isInPeakWindow(new Date(now()), peakHours)) { + try { + auditPeakSkip?.({ count, chars }); + } catch (error) { + // 记账是 best-effort:写审计行失败只 warn,绝不反噬调度本身。 + logger?.warn?.(`dsh-mneme dream: peak-hours audit failed: ${String(error)}`); + } + scheduleDeferredRun(service); + return false; + } + pendingTimer = setTimeoutFn(() => { pendingTimer = null; - running = true; - lastRunAt = Date.now(); - // Defer the onRun invocation so a synchronous throw cannot escape the - // timer callback (which would crash the process) and skip the teardown. - // Errors are logged, never swallowed silently. inFlight lets dispose() - // await the running consolidation before the caller closes the store. - inFlight = Promise.resolve() - .then(() => (onRun ? onRun() : Promise.resolve({ ok: true, skipped: true }))) - .then((result) => { - // Refresh the baseline only for a successful run (design §5.3: an - // LLM failure must not move the baseline, so the next write can - // immediately re-trigger a retry). A `{ok:false}` result or a throw - // keeps the old baseline. A run that reports nothing is treated as - // completed without failure (no-op hooks / minimal test doubles). - if (result && result.ok) { - try { - baseline = shouldTrigger(service); - } catch (error) { - // Store closed mid-flight: keep the last known baseline. - logger?.warn?.(`dsh-mneme dream: baseline refresh failed: ${String(error)}`); - } - } - }) - .catch((error) => { - logger?.warn?.(`dsh-mneme dream: run failed: ${error?.message ?? error}`); - // Failed runs do not refresh the baseline. - }) - .finally(() => { - running = false; - inFlight = null; - }); + startRun(service); }, delayMs); return true; } + /** + * Issue #239:高峰内择时补跑——挂到「距当前最近的一个高峰结束时刻」,被 + * peakMaxDeferMinutes 截断时到点照跑(bypassPeak),长高峰不会把巩固饿死。 + * 定时器 unref:不阻止宿主退出。重复触发不叠加(deferTimer 已在 maybeSchedule + * 的守卫里,这里再判一次以防从其它路径进来)。 + */ + function scheduleDeferredRun(service) { + if (disposed || deferTimer) return; + const at = nextOffPeakAt(new Date(now()), peakHours); + if (!at) return; + const maxDeferMs = (peakMaxDeferMinutes ?? 0) * 60000; + let delay = Math.max(0, at.getTime() - now()); + const capped = maxDeferMs > 0 && delay > maxDeferMs; + if (capped) delay = maxDeferMs; + deferTimer = setTimeoutFn(() => { + deferTimer = null; + if (disposed) return; + // 截断放行时仍在高峰:不再重新顺延(否则长高峰里会无限顺延,等于把巩固 + // 关掉)。直接开跑,与蒸馏的 bypassPeak 同口径。 + if (!capped && isInPeakWindow(new Date(now()), peakHours)) { + // 理论上到点已出高峰;时钟跳变/时段串被改小可能落回高峰内,此时再顺延一次。 + scheduleDeferredRun(service); + return; + } + logger?.info?.(`dsh-mneme dream: peak-hours deferred run firing (capped=${capped}, delayMs=${delay})`); + startRun(service); + }, delay); + deferTimer.unref?.(); + } + + /** + * 真正开跑。抽出来是因为两条路径都要用:写入触发的正常路径,与高峰顺延后的 + * 补跑路径。onRun 的调用刻意放在 Promise 里——同步抛出的异常若逃出 timer 回调 + * 会直接崩掉进程并跳过收尾。inFlight 让 dispose() 能等完这一轮再关库。 + */ + function startRun(service) { + running = true; + lastRunAt = now(); + inFlight = Promise.resolve() + .then(() => (onRun ? onRun() : Promise.resolve({ ok: true, skipped: true }))) + .then((result) => { + // Refresh the baseline only for a successful run (design §5.3: an + // LLM failure must not move the baseline, so the next write can + // immediately re-trigger a retry). A `{ok:false}` result or a throw + // keeps the old baseline. A run that reports nothing is treated as + // completed without failure (no-op hooks / minimal test doubles). + if (result && result.ok) { + try { + baseline = shouldTrigger(service); + } catch (error) { + // Store closed mid-flight: keep the last known baseline. + logger?.warn?.(`dsh-mneme dream: baseline refresh failed: ${String(error)}`); + } + } + }) + .catch((error) => { + logger?.warn?.(`dsh-mneme dream: run failed: ${error?.message ?? error}`); + // Failed runs do not refresh the baseline. + }) + .finally(() => { + running = false; + inFlight = null; + }); + } + async function dispose() { disposed = true; - if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; } + if (pendingTimer) { clearTimeoutFn(pendingTimer); pendingTimer = null; } + // Issue #239:高峰顺延定时器同样要清,否则进程关闭后仍会触发一次巩固。 + if (deferTimer) { clearTimeoutFn(deferTimer); deferTimer = null; } // An in-flight run is left to complete naturally (its LLM calls are // already paid for and aborting would discard the work). Await it so the // caller can close the store only after every write has landed. diff --git a/dsh-mneme/lib/index.js b/dsh-mneme/lib/index.js index aef528a2..db257cd9 100644 --- a/dsh-mneme/lib/index.js +++ b/dsh-mneme/lib/index.js @@ -494,6 +494,32 @@ export const apply = (ctx, config) => { logger: ctx.logger, semantic: { embedder, vectorIndex }, lastRunAtSeed: store.lastDreamRunAt("auto"), + // Issue #239(第 4 项)镜像到巩固:高峰期不做梦,顺延到最近的高峰结束时刻。 + peakHours: cfg.dreamPeakHours ?? "", + peakMaxDeferMinutes: cfg.dreamPeakMaxDeferMinutes ?? 120, + // 跳过时的审计行在这里落地(调度器只拿到 service,拿不到 config 的 + // llmAudit 开关与巩固模型路由)。口径与 runAuditedLlm 一致:审计关掉就 + // 不写;写失败只 warn,绝不反噬调度(CONTRIBUTING 的 fail-safe 硬约定)。 + auditPeakSkip: ({ count, chars }) => { + if (cfg?.llmAudit?.enabled === false || typeof service?.saveLlmAudit !== "function") return; + const modelId = cfg.dreamProvider && cfg.dreamModel ? `${cfg.dreamProvider}:${cfg.dreamModel}` : ""; + service.saveLlmAudit({ + timestamp: new Date().toISOString(), + trigger_source: "autoDream", + operation_type: "dream_consolidate", + model_id: modelId, + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + cost_usd: 0, + duration_ms: 0, + status: "skipped", + error_message: "peak-hours", + related_memory_ids: [], + // 观测用:跳过时窗口里积了多少(阈值继续累积,不是丢弃)。 + metadata: JSON.stringify({ count, chars }) + }); + }, onRun: () => (dream ? dream.runDream(ctx, service, cfg) : Promise.resolve({ ok: true, skipped: true })) }); service.setDreamHook(() => dream.maybeSchedule(service)); diff --git a/dsh-mneme/lib/settings.js b/dsh-mneme/lib/settings.js index a6888f19..6e6d6a8c 100644 --- a/dsh-mneme/lib/settings.js +++ b/dsh-mneme/lib/settings.js @@ -117,6 +117,8 @@ const FEATURE_FLAG_INT_RANGES = { summarizeMaxRunsPerSession: [0, 1000], // Issue #239 第 4 项:高峰顺延上限(分钟,0 = 不设上限)。 summarizePeakMaxDeferMinutes: [0, 1440], + // Issue #239 第 4 项镜像到巩固:同一口径(分钟,0 = 不设上限)。 + dreamPeakMaxDeferMinutes: [0, 1440], // Issue #125:hybrid 候选量上限(0 = 复用 dreamMaxSnapshotSize)。 dreamCandidateMax: [0, 5000], // Issue #164①:注入单条正文截断上限(默认 300 = 既有行为)。 @@ -159,7 +161,9 @@ const FEATURE_FLAG_STRINGS = [ "localEmbedModel", "ollamaModel", // Issue #239 第 4 项:高峰时段串("09:00-18:00",空串 = 关闭)。 - "summarizePeakHours" + "summarizePeakHours", + // Issue #239 第 4 项镜像到巩固:同一份时段语法,空串 = 关闭(行为与现状一致)。 + "dreamPeakHours" ]; // URL 字符串开关:trim 后必须为空或合法 http/https URL(new URL() 校验协议, // 拒绝其余协议——这是 SSRF 防线的一部分)。 diff --git a/dsh-mneme/src/config.js b/dsh-mneme/src/config.js index c62c1784..4c68f9a1 100644 --- a/dsh-mneme/src/config.js +++ b/dsh-mneme/src/config.js @@ -145,6 +145,22 @@ export const Config = z.object({ // 起算,失败/degraded 的 run 也占用间隔;间隔内的触发请求静默跳过,下一次 // 写入事件会重新评估。 dreamMinIntervalMinutes: z.natural().min(0).max(10080).default(0), + // Issue #239(第 4 项,错峰队列)镜像到巩固:高峰期不做梦。与 + // summarizePeakHours 同一份时段语法(复用 src/summarize.js 的 parsePeakSpec / + // isInPeakWindow / nextOffPeakAt,不另写解析器):逗号分隔、可带星期前缀、支持 + // 跨零点。空串 = 关闭,行为与现状逐字节一致。 + // "09:00-18:00" 每天 09:00-18:00 + // "mon-fri 08:00-12:00,14:00-18:00" 工作日两段(按高峰计费的供应商即此形态) + // 为什么巩固比蒸馏更该有这道闸:单次巩固的输入是整窗快照(dreamMaxSnapshotSize + // 条),实测一次 run 的 LLM 时长可达数分钟量级,撞上高峰时既贵又慢;而它由写入 + // 事件触发、没有天然的「等到空闲再跑」路径。命中高峰时:不调 LLM、不刷新 + // baseline(阈值继续累积,留到非高峰一次性巩固),登记一行 status='skipped' / + // error_message='peak-hours' 审计,并按下面的上限择时补跑。任一写法非法则整串 + // 按「未配置」处理——排程是省钱手段,绝不该因为写错格式把巩固停掉。 + dreamPeakHours: z.string().default(""), + // 高峰顺延上限(分钟,0 = 不设上限):到点仍处高峰就照常跑,避免整天高峰把巩固 + // 饿死。默认 120,与 summarizePeakMaxDeferMinutes 对齐。仅在时段串非空时生效。 + dreamPeakMaxDeferMinutes: z.natural().min(0).max(1440).default(120), // 巩固模型路由(settings panel「巩固模型」/ dreamProvider+dreamModel): // dream 的记忆沉淀专用 LLM 路由,显式配置优先于 agent 默认模型(config-first, // Issue #25)。模型分类声明: diff --git a/dsh-mneme/src/dream.js b/dsh-mneme/src/dream.js index 3a34c6b5..d3d472b1 100644 --- a/dsh-mneme/src/dream.js +++ b/dsh-mneme/src/dream.js @@ -2,6 +2,11 @@ import { validateDecisions, applyDecisions } from "./dream/decisions.js"; import { clusterMemories, findPotentialConflicts, cosineSimilarity } from "./dream/clustering.js"; import { clusterByTag, intersectEvidence } from "./dream/narratives.js"; import { scopeKeyOf } from "./scope.js"; +// Issue #239(第 4 项)镜像到巩固:错峰时段解析与「最近的高峰结束时刻」直接复用 +// 蒸馏侧已导出的纯函数,不另写一份解析器——两份实现漂移会让「同一个时段串在两处 +// 行为不同」,那比没有这个功能更糟。summarize.js 只依赖 dsh-llm 与 lang.js, +// 不反向依赖 dream.js,无循环引用。 +import { isInPeakWindow, nextOffPeakAt } from "./summarize.js"; import { createHash, randomUUID } from "node:crypto"; import { STR, langOf } from "./lang.js"; export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure, resolveDreamEffort, resolveRoute }; @@ -750,8 +755,12 @@ export async function maintainIndexAfterDream(decisions, service, semantic) { if (embedder.modelHash) vectorIndex.markModel?.(embedder.modelHash, embedder.dimension); } -export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, logger, semantic = null, lastRunAtSeed = 0 }) { +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 }) { let pendingTimer = null; + // Issue #239(第 4 项)镜像到巩固:高峰顺延定时器。与 pendingTimer 分开——两者 + // 语义不同(一个是「马上要跑」,一个是「等出高峰再跑」),合成一个变量会让 + // maybeSchedule 的守卫在顺延期间把新的写入触发误当成「已有待跑」而吞掉。 + let deferTimer = null; let running = false; let disposed = false; let baseline = { count: 0, chars: 0 }; @@ -772,51 +781,103 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar } function maybeSchedule(service) { - if (disposed || running || pendingTimer) return false; + if (disposed || running || pendingTimer || deferTimer) return false; // Issue #89(请求 2):最小触发间隔闸门。 - if (minIntervalMs > 0 && Date.now() - lastRunAt < minIntervalMs) return false; + if (minIntervalMs > 0 && now() - lastRunAt < minIntervalMs) return false; const { trigger, count, chars } = shouldTrigger(service); if (!trigger) return false; - pendingTimer = setTimeout(() => { + // Issue #239(第 4 项,错峰队列)镜像到巩固:命中高峰就不调模型。与蒸馏的差别 + // 在于巩固是**全局单实例**(蒸馏按会话各挂一个定时器),所以这里只需要一个 + // deferTimer,且不需要 deferredRuns 那套按会话去重。 + // baseline 刻意不刷新:阈值继续累积,留到非高峰一次性巩固(一次大 run 比多次 + // 小 run 省)。审计只登记一行 skip——「为什么不再做梦了」必须对用户可观测。 + if (isInPeakWindow(new Date(now()), peakHours)) { + try { + auditPeakSkip?.({ count, chars }); + } catch (error) { + // 记账是 best-effort:写审计行失败只 warn,绝不反噬调度本身。 + logger?.warn?.(`dsh-mneme dream: peak-hours audit failed: ${String(error)}`); + } + scheduleDeferredRun(service); + return false; + } + pendingTimer = setTimeoutFn(() => { pendingTimer = null; - running = true; - lastRunAt = Date.now(); - // Defer the onRun invocation so a synchronous throw cannot escape the - // timer callback (which would crash the process) and skip the teardown. - // Errors are logged, never swallowed silently. inFlight lets dispose() - // await the running consolidation before the caller closes the store. - inFlight = Promise.resolve() - .then(() => (onRun ? onRun() : Promise.resolve({ ok: true, skipped: true }))) - .then((result) => { - // Refresh the baseline only for a successful run (design §5.3: an - // LLM failure must not move the baseline, so the next write can - // immediately re-trigger a retry). A `{ok:false}` result or a throw - // keeps the old baseline. A run that reports nothing is treated as - // completed without failure (no-op hooks / minimal test doubles). - if (result && result.ok) { - try { - baseline = shouldTrigger(service); - } catch (error) { - // Store closed mid-flight: keep the last known baseline. - logger?.warn?.(`dsh-mneme dream: baseline refresh failed: ${String(error)}`); - } - } - }) - .catch((error) => { - logger?.warn?.(`dsh-mneme dream: run failed: ${error?.message ?? error}`); - // Failed runs do not refresh the baseline. - }) - .finally(() => { - running = false; - inFlight = null; - }); + startRun(service); }, delayMs); return true; } + /** + * Issue #239:高峰内择时补跑——挂到「距当前最近的一个高峰结束时刻」,被 + * peakMaxDeferMinutes 截断时到点照跑(bypassPeak),长高峰不会把巩固饿死。 + * 定时器 unref:不阻止宿主退出。重复触发不叠加(deferTimer 已在 maybeSchedule + * 的守卫里,这里再判一次以防从其它路径进来)。 + */ + function scheduleDeferredRun(service) { + if (disposed || deferTimer) return; + const at = nextOffPeakAt(new Date(now()), peakHours); + if (!at) return; + const maxDeferMs = (peakMaxDeferMinutes ?? 0) * 60000; + let delay = Math.max(0, at.getTime() - now()); + const capped = maxDeferMs > 0 && delay > maxDeferMs; + if (capped) delay = maxDeferMs; + deferTimer = setTimeoutFn(() => { + deferTimer = null; + if (disposed) return; + // 截断放行时仍在高峰:不再重新顺延(否则长高峰里会无限顺延,等于把巩固 + // 关掉)。直接开跑,与蒸馏的 bypassPeak 同口径。 + if (!capped && isInPeakWindow(new Date(now()), peakHours)) { + // 理论上到点已出高峰;时钟跳变/时段串被改小可能落回高峰内,此时再顺延一次。 + scheduleDeferredRun(service); + return; + } + logger?.info?.(`dsh-mneme dream: peak-hours deferred run firing (capped=${capped}, delayMs=${delay})`); + startRun(service); + }, delay); + deferTimer.unref?.(); + } + + /** + * 真正开跑。抽出来是因为两条路径都要用:写入触发的正常路径,与高峰顺延后的 + * 补跑路径。onRun 的调用刻意放在 Promise 里——同步抛出的异常若逃出 timer 回调 + * 会直接崩掉进程并跳过收尾。inFlight 让 dispose() 能等完这一轮再关库。 + */ + function startRun(service) { + running = true; + lastRunAt = now(); + inFlight = Promise.resolve() + .then(() => (onRun ? onRun() : Promise.resolve({ ok: true, skipped: true }))) + .then((result) => { + // Refresh the baseline only for a successful run (design §5.3: an + // LLM failure must not move the baseline, so the next write can + // immediately re-trigger a retry). A `{ok:false}` result or a throw + // keeps the old baseline. A run that reports nothing is treated as + // completed without failure (no-op hooks / minimal test doubles). + if (result && result.ok) { + try { + baseline = shouldTrigger(service); + } catch (error) { + // Store closed mid-flight: keep the last known baseline. + logger?.warn?.(`dsh-mneme dream: baseline refresh failed: ${String(error)}`); + } + } + }) + .catch((error) => { + logger?.warn?.(`dsh-mneme dream: run failed: ${error?.message ?? error}`); + // Failed runs do not refresh the baseline. + }) + .finally(() => { + running = false; + inFlight = null; + }); + } + async function dispose() { disposed = true; - if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; } + if (pendingTimer) { clearTimeoutFn(pendingTimer); pendingTimer = null; } + // Issue #239:高峰顺延定时器同样要清,否则进程关闭后仍会触发一次巩固。 + if (deferTimer) { clearTimeoutFn(deferTimer); deferTimer = null; } // An in-flight run is left to complete naturally (its LLM calls are // already paid for and aborting would discard the work). Await it so the // caller can close the store only after every write has landed. diff --git a/dsh-mneme/src/index.js b/dsh-mneme/src/index.js index aef528a2..db257cd9 100644 --- a/dsh-mneme/src/index.js +++ b/dsh-mneme/src/index.js @@ -494,6 +494,32 @@ export const apply = (ctx, config) => { logger: ctx.logger, semantic: { embedder, vectorIndex }, lastRunAtSeed: store.lastDreamRunAt("auto"), + // Issue #239(第 4 项)镜像到巩固:高峰期不做梦,顺延到最近的高峰结束时刻。 + peakHours: cfg.dreamPeakHours ?? "", + peakMaxDeferMinutes: cfg.dreamPeakMaxDeferMinutes ?? 120, + // 跳过时的审计行在这里落地(调度器只拿到 service,拿不到 config 的 + // llmAudit 开关与巩固模型路由)。口径与 runAuditedLlm 一致:审计关掉就 + // 不写;写失败只 warn,绝不反噬调度(CONTRIBUTING 的 fail-safe 硬约定)。 + auditPeakSkip: ({ count, chars }) => { + if (cfg?.llmAudit?.enabled === false || typeof service?.saveLlmAudit !== "function") return; + const modelId = cfg.dreamProvider && cfg.dreamModel ? `${cfg.dreamProvider}:${cfg.dreamModel}` : ""; + service.saveLlmAudit({ + timestamp: new Date().toISOString(), + trigger_source: "autoDream", + operation_type: "dream_consolidate", + model_id: modelId, + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + cost_usd: 0, + duration_ms: 0, + status: "skipped", + error_message: "peak-hours", + related_memory_ids: [], + // 观测用:跳过时窗口里积了多少(阈值继续累积,不是丢弃)。 + metadata: JSON.stringify({ count, chars }) + }); + }, onRun: () => (dream ? dream.runDream(ctx, service, cfg) : Promise.resolve({ ok: true, skipped: true })) }); service.setDreamHook(() => dream.maybeSchedule(service)); diff --git a/dsh-mneme/src/settings.js b/dsh-mneme/src/settings.js index a6888f19..6e6d6a8c 100644 --- a/dsh-mneme/src/settings.js +++ b/dsh-mneme/src/settings.js @@ -117,6 +117,8 @@ const FEATURE_FLAG_INT_RANGES = { summarizeMaxRunsPerSession: [0, 1000], // Issue #239 第 4 项:高峰顺延上限(分钟,0 = 不设上限)。 summarizePeakMaxDeferMinutes: [0, 1440], + // Issue #239 第 4 项镜像到巩固:同一口径(分钟,0 = 不设上限)。 + dreamPeakMaxDeferMinutes: [0, 1440], // Issue #125:hybrid 候选量上限(0 = 复用 dreamMaxSnapshotSize)。 dreamCandidateMax: [0, 5000], // Issue #164①:注入单条正文截断上限(默认 300 = 既有行为)。 @@ -159,7 +161,9 @@ const FEATURE_FLAG_STRINGS = [ "localEmbedModel", "ollamaModel", // Issue #239 第 4 项:高峰时段串("09:00-18:00",空串 = 关闭)。 - "summarizePeakHours" + "summarizePeakHours", + // Issue #239 第 4 项镜像到巩固:同一份时段语法,空串 = 关闭(行为与现状一致)。 + "dreamPeakHours" ]; // URL 字符串开关:trim 后必须为空或合法 http/https URL(new URL() 校验协议, // 拒绝其余协议——这是 SSRF 防线的一部分)。 diff --git a/dsh-mneme/test/api.test.js b/dsh-mneme/test/api.test.js index 5fc474a9..5bd7b91b 100644 --- a/dsh-mneme/test/api.test.js +++ b/dsh-mneme/test/api.test.js @@ -665,8 +665,9 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d // documentInjectBudget、issue #249 第一批新增 injectGuidanceEnabled/ // pinnedInjectBudget、issue #249 N3 新增 continuityRescueEnabled, // v0.8.5 新增 disableMemorySearch/disableMemoryArchive, - // 本地嵌入池化新增 localEmbedPooling,issue #315 新增 summarizeReasoningEffort) - assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1); + // 本地嵌入池化新增 localEmbedPooling,issue #315 新增 summarizeReasoningEffort, + // issue #239 第 4 项镜像到巩固新增 dreamPeakHours/dreamPeakMaxDeferMinutes) + assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2); assert.equal(data.effective.dreamSkipInvalid, true); assert.equal(data.effective.allowCrossTypeMerge, false); assert.equal(data.effective.dreamMinIntervalMinutes, 0); diff --git a/dsh-mneme/test/dream-peak-hours.test.js b/dsh-mneme/test/dream-peak-hours.test.js new file mode 100644 index 00000000..8847a074 --- /dev/null +++ b/dsh-mneme/test/dream-peak-hours.test.js @@ -0,0 +1,194 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createDreamScheduler } from "../src/dream.js"; +import { createStore } from "../src/store.js"; +import { createService } from "../src/service.js"; + +// Issue #239(第 4 项,错峰队列)镜像到巩固:dreamPeakHours / dreamPeakMaxDeferMinutes。 +// +// 这些用例锁的是「排程语义」,不是实现细节——它们防的回归是: +// ① 错峰闸门被写反/失效 → 高峰期又开始做梦(省钱能力静默消失); +// ② 顺延路径把 skip 做成静默 → 用户看到「不再巩固了」却查不出原因; +// ③ 顺延没有上限 → 长高峰(如全天)把巩固永久饿死; +// ④ 非法时段串被当成「全天高峰」→ 一个笔误把功能整个停掉; +// ⑤ dispose 漏清顺延定时器 → 进程关掉后仍触发一次巩固。 +// +// 时钟与定时器全部注入(同 src/dream/sleep.js 的房型):排程不绑死真实时钟, +// 「高峰顺延 → 非高峰补跑」才能被确定性覆盖,也不会在 CI 上留下真实等待。 +function dreamSetup() { + const store = createStore(":memory:"); + const service = createService({ store, mirror: null, config: {} }); + return { store, service }; +} + +/** 本地墙钟时间:用本地时区构造,getHours() 在 ubuntu/windows CI 上都等于入参。 */ +function at(hour, minute = 0) { + return new Date(2026, 5, 15, hour, minute, 0, 0).getTime(); +} + +function fakeClock(startMs) { + let nowMs = startMs; + const timers = []; + let seq = 1; + return { + now: () => nowMs, + setNow: (v) => { nowMs = v; }, + timers, + setTimeoutFn: (fn, delay) => { const t = { id: seq++, at: nowMs + delay, fn }; timers.push(t); return t.id; }, + clearTimeoutFn: (id) => { const i = timers.findIndex((t) => t.id === id); if (i >= 0) timers.splice(i, 1); } + }; +} + +/** 造出「超过阈值」的写入量(thresholdCount=2)。 */ +function fill(service, n = 2) { + for (let i = 0; i < n; i++) service.saveWithDedupe({ type: "project", title: `m${i}`, content: "x".repeat(50) }); +} + +test("dreamPeakHours: 命中高峰不调模型,登记 skip 审计并顺延", () => { + const { store, service } = dreamSetup(); + const clock = fakeClock(at(10, 0)); // 本地 10:00,落在 09:00-18:00 内 + let runs = 0; + const audits = []; + const dream = createDreamScheduler({ + onRun: async () => { runs++; return { ok: true }; }, + thresholdCount: 2, thresholdChars: 5000, delayMs: 0, + peakHours: "09:00-18:00", + peakMaxDeferMinutes: 0, // 不设上限:顺延应落在真正的高峰结束时刻 + now: clock.now, setTimeoutFn: clock.setTimeoutFn, clearTimeoutFn: clock.clearTimeoutFn, + auditPeakSkip: (info) => audits.push(info) + }); + fill(service); + assert.equal(dream.maybeSchedule(service), false, "高峰内不排「马上跑」"); + assert.equal(runs, 0, "高峰内绝不调 LLM"); + assert.equal(audits.length, 1, "skip 必须留痕,否则「为什么不再巩固」不可观测"); + assert.equal(audits[0].count, 2, "审计带上窗口里积了多少(阈值继续累积,不是丢弃)"); + assert.equal(clock.timers.length, 1, "挂了一个顺延定时器"); + assert.equal(clock.timers[0].at, at(18, 0), "顺延到最近的高峰结束时刻"); + store.close(); +}); + +test("dreamPeakHours: 顺延到点后补跑(非高峰真的会跑)", async () => { + const { store, service } = dreamSetup(); + const clock = fakeClock(at(10, 0)); + let runs = 0; + const dream = createDreamScheduler({ + onRun: async () => { runs++; return { ok: true }; }, + thresholdCount: 2, thresholdChars: 5000, delayMs: 0, + peakHours: "09:00-18:00", + now: clock.now, setTimeoutFn: clock.setTimeoutFn, clearTimeoutFn: clock.clearTimeoutFn, + auditPeakSkip: () => {} + }); + fill(service); + dream.maybeSchedule(service); + const deferred = clock.timers[0]; + clock.setNow(deferred.at); // 走到 18:00 + await deferred.fn(); + assert.equal(runs, 1, "出高峰后补跑一次"); + store.close(); +}); + +test("dreamPeakHours: 重复触发不叠加顺延定时器", () => { + const { store, service } = dreamSetup(); + const clock = fakeClock(at(10, 0)); + let audits = 0; + const dream = createDreamScheduler({ + onRun: async () => ({ ok: true }), + thresholdCount: 2, thresholdChars: 5000, delayMs: 0, + peakHours: "09:00-18:00", + now: clock.now, setTimeoutFn: clock.setTimeoutFn, clearTimeoutFn: clock.clearTimeoutFn, + auditPeakSkip: () => { audits++; } + }); + fill(service); + dream.maybeSchedule(service); + dream.maybeSchedule(service); // 顺延期间又来一次写入触发 + assert.equal(clock.timers.length, 1, "同一时刻只有一个顺延定时器"); + assert.equal(audits, 1, "不重复刷审计行"); + store.close(); +}); + +test("dreamPeakMaxDeferMinutes: 上限截断后到点照跑,长高峰不饿死巩固", async () => { + const { store, service } = dreamSetup(); + const clock = fakeClock(at(10, 0)); + let runs = 0; + const dream = createDreamScheduler({ + onRun: async () => { runs++; return { ok: true }; }, + thresholdCount: 2, thresholdChars: 5000, delayMs: 0, + peakHours: "00:00-23:59", // 全天高峰:不设上限就会顺延到 23:59 + peakMaxDeferMinutes: 120, + now: clock.now, setTimeoutFn: clock.setTimeoutFn, clearTimeoutFn: clock.clearTimeoutFn, + auditPeakSkip: () => {} + }); + fill(service); + dream.maybeSchedule(service); + const t = clock.timers[0]; + assert.equal(t.at, at(12, 0), "被上限截断到 120 分钟后(而不是等到 23:59)"); + clock.setNow(t.at); + await t.fn(); + assert.equal(runs, 1, "截断放行:仍在高峰也照跑,避免无限顺延"); + store.close(); +}); + +test("dreamPeakHours: 非法时段串按「未配置」处理(宁可不省,也不能误停)", async () => { + const { store, service } = dreamSetup(); + const clock = fakeClock(at(10, 0)); + let runs = 0; + let audits = 0; + const dream = createDreamScheduler({ + onRun: async () => { runs++; return { ok: true }; }, + thresholdCount: 2, thresholdChars: 5000, delayMs: 0, + peakHours: "10:00-99:00", // 非法:解析失败必须回落成「关闭」 + now: clock.now, setTimeoutFn: clock.setTimeoutFn, clearTimeoutFn: clock.clearTimeoutFn, + auditPeakSkip: () => { audits++; } + }); + fill(service); + assert.equal(dream.maybeSchedule(service), true, "非法串 = 未配置 → 走原有触发路径"); + assert.equal(audits, 0, "不写 skip 审计"); + const t = clock.timers[0]; + clock.setNow(t.at); + await t.fn(); + assert.equal(runs, 1, "巩固照常进行"); + store.close(); +}); + +test("dreamPeakHours: 非高峰时段不受影响(行为与现状一致)", async () => { + const { store, service } = dreamSetup(); + const clock = fakeClock(at(20, 0)); // 本地 20:00,在 09:00-18:00 之外 + let runs = 0; + const dream = createDreamScheduler({ + onRun: async () => { runs++; return { ok: true }; }, + thresholdCount: 2, thresholdChars: 5000, delayMs: 5, + peakHours: "09:00-18:00", + now: clock.now, setTimeoutFn: clock.setTimeoutFn, clearTimeoutFn: clock.clearTimeoutFn, + auditPeakSkip: () => { throw new Error("非高峰不该写 skip 审计"); } + }); + fill(service); + assert.equal(dream.maybeSchedule(service), true, "非高峰照常排程"); + const t = clock.timers[0]; + assert.equal(t.at, at(20, 0) + 5, "走的是 delayMs 的「马上跑」路径,不是顺延路径"); + clock.setNow(t.at); + await t.fn(); + assert.equal(runs, 1); + store.close(); +}); + +test("dreamPeakHours: dispose 清掉顺延定时器,进程关闭后不再触发巩固", async () => { + const { store, service } = dreamSetup(); + const clock = fakeClock(at(10, 0)); + let runs = 0; + const dream = createDreamScheduler({ + onRun: async () => { runs++; return { ok: true }; }, + thresholdCount: 2, thresholdChars: 5000, delayMs: 0, + peakHours: "09:00-18:00", + now: clock.now, setTimeoutFn: clock.setTimeoutFn, clearTimeoutFn: clock.clearTimeoutFn, + auditPeakSkip: () => {} + }); + fill(service); + dream.maybeSchedule(service); + const t = clock.timers[0]; + await dream.dispose(); + assert.equal(clock.timers.length, 0, "顺延定时器已清"); + clock.setNow(t.at); + await t.fn(); // 模拟「定时器已经进了事件队列,清不掉」的那一帧 + assert.equal(runs, 0, "disposed 守卫拦住补跑"); + store.close(); +}); From deee4bd70eee7ce6a1556ceed38827137a806cf7 Mon Sep 17 00:00:00 2001 From: xain <233244+xain@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:27:44 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(client):=20=E5=90=88=E5=B9=B6=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E7=9A=84=20summarizeSub=20=E5=A3=B0=E6=98=8E=20+=20?= =?UTF-8?q?=E8=A1=A5=E9=9D=A2=E6=9D=BF=E4=BA=A7=E7=89=A9=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E9=97=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review 抓到的真问题:rebase 到含 #316(summarizeReasoningEffort)的 main 时,我在 autoSummarize 子块新加的 `const summarizeSub` 与上游同名声明撞车—— `node --check lib/client.js` 报 "Identifier 'summarizeSub' has already been declared", 面板 bundle 一执行就抛 SyntaxError,整个设置页加载失败。而当时 CI 全绿。 - 把错峰时段输入**并入上游那个 summarizeSub**(保留思考强度下拉与其 hint), 而不是并列第二个声明。 - 补一道解析闸(test/client.test.js):该文件此前只把面板当**文本**读(断言全是 正则/字符串包含),仓库 CI 里没有任何一步解析 lib/client.js,而 lib/client.js 无 src 对应物、不参与 sync —— 于是这类语法错误能全绿进主干。新增用例用 `node --check` 解析产物;已实测「注入重复声明 → 该用例失败、还原后通过」。 - 全量 npm test:1399 条,失败集合仍与上游 main 逐条一致(9 条 runtime-verify 环境相关用例)。 --- dsh-mneme/lib/client.js | 13 +++++-------- dsh-mneme/test/client.test.js | 14 +++++++++++++- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js index 0db12535..7d7f296e 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -2227,13 +2227,6 @@ window.__ModuleLoader__.load({ eff.embedProvider === "ollama" && strRow("ollamaModel") ); - // Issue #239 第 4 项:蒸馏侧错峰时段串(空 = 关闭)。挂在 autoSummarize - // 下面——与巩固侧对称,两处错峰开关都能在面板上调。 - const summarizeSub = eff.autoSummarize && h("div", { className: "mneme-featsub" }, - strRow("summarizePeakHours"), - h("div", { className: "mneme-featsubhint" }, t("memory.features.summarizePeakHours.hint")) - ); - // 巩固模型:autoDream 开着才展开,避免闲置配置占版面。/llm-providers // 可用时用级联下拉 + 连通性测试;旧后端(端点 404)回退纯文本输入。 const dreamSub = eff.autoDream && h("div", { className: "mneme-featsub" }, @@ -2288,7 +2281,11 @@ window.__ModuleLoader__.load({ }, SUMMARIZE_REASONING.map((r) => h("option", { key: r, value: r }, t(`memory.features.summarizeReasoningEffort.${r}`)))) ), - h("div", { className: "mneme-featsubhint" }, t("memory.features.summarizeReasoningEffort.hint")) + h("div", { className: "mneme-featsubhint" }, t("memory.features.summarizeReasoningEffort.hint")), + // Issue #239 第 4 项:蒸馏侧错峰时段串(空 = 关闭)。与思考强度同处一个 + // autoSummarize 子块——两处错峰开关都能在面板上调(巩固侧见 dreamSub)。 + strRow("summarizePeakHours"), + h("div", { className: "mneme-featsubhint" }, t("memory.features.summarizePeakHours.hint")) ); if (error && !state) return h("section", { className: "mneme-set-card" }, diff --git a/dsh-mneme/test/client.test.js b/dsh-mneme/test/client.test.js index 83e5f602..4813494e 100644 --- a/dsh-mneme/test/client.test.js +++ b/dsh-mneme/test/client.test.js @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { readFileSync, existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const clientSource = readFileSync(join(root, "lib/client.js"), "utf8"); @@ -943,4 +944,15 @@ test("a11y+preview: inject preview card is wired on the status tab", () => { const occurrences = clientSource.split(`"${key}"`).length - 1; assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`); } -}); \ No newline at end of file +}); +// 面板 bundle 在本文件里只被当**文本**读(上面的断言全是正则/字符串包含), +// 而仓库的 CI 里没有任何一步**解析**它:于是重复声明这类语法错误能一路绿灯进 +// 主干,后果却是整个面板加载失败(__ModuleLoader__ 拿到的模块一执行就抛 +// SyntaxError)。lib/client.js 无 src 对应物、不参与 sync,也就没有别的闸门 +// 覆盖它——这里补一道解析闸(PR #320 rebase 时真实踩到:与上游新增的 +// summarizeSub 撞名,node --check 报 "Identifier 'summarizeSub' has already +// been declared",而当时 CI 全绿)。 +test("client bundle parses: 面板产物必须是合法 JS(无重复声明等语法错误)", () => { + const result = spawnSync(process.execPath, ["--check", join(root, "lib/client.js")], { encoding: "utf8" }); + assert.equal(result.status, 0, `lib/client.js 解析失败:\n${result.stderr || result.stdout}`); +}); From 7e48c9732737bbd35e5dbff98fd3cdd7fff8fcda Mon Sep 17 00:00:00 2001 From: modusensus Date: Sun, 27 Sep 2026 00:19:36 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(dream):=20=E9=94=99=E5=B3=B0=20helper?= =?UTF-8?q?=20=E6=8A=BD=E7=8B=AC=E7=AB=8B=E6=A8=A1=E5=9D=97=20peak-hours.j?= =?UTF-8?q?s=EF=BC=8C=E6=B6=88=E9=99=A4=20dream=E2=86=94summarize=20?= =?UTF-8?q?=E5=BE=AA=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review #320:#316 之后 summarize.js 反向依赖 dream.js(withEffortFallback 复用),dream 镜像错峰再 import summarize 会成真循环。把 parsePeakSpec / isInPeakWindow / nextOffPeakAt 抽到零依赖模块 src/peak-hours.js(纯搬移), summarize.js import + re-export 兼容既有调用方与测试,dream.js 改从新模块 import。顺带修正原注释里「summarize 不反向依赖 dream」的过时事实。 --- dsh-mneme/lib/dream.js | 11 ++-- dsh-mneme/lib/peak-hours.js | 117 +++++++++++++++++++++++++++++++++++ dsh-mneme/lib/summarize.js | 119 +++--------------------------------- dsh-mneme/src/dream.js | 11 ++-- dsh-mneme/src/peak-hours.js | 117 +++++++++++++++++++++++++++++++++++ dsh-mneme/src/summarize.js | 119 +++--------------------------------- 6 files changed, 262 insertions(+), 232 deletions(-) create mode 100644 dsh-mneme/lib/peak-hours.js create mode 100644 dsh-mneme/src/peak-hours.js diff --git a/dsh-mneme/lib/dream.js b/dsh-mneme/lib/dream.js index d3d472b1..8ddc37c2 100644 --- a/dsh-mneme/lib/dream.js +++ b/dsh-mneme/lib/dream.js @@ -2,11 +2,12 @@ import { validateDecisions, applyDecisions } from "./dream/decisions.js"; import { clusterMemories, findPotentialConflicts, cosineSimilarity } from "./dream/clustering.js"; import { clusterByTag, intersectEvidence } from "./dream/narratives.js"; import { scopeKeyOf } from "./scope.js"; -// Issue #239(第 4 项)镜像到巩固:错峰时段解析与「最近的高峰结束时刻」直接复用 -// 蒸馏侧已导出的纯函数,不另写一份解析器——两份实现漂移会让「同一个时段串在两处 -// 行为不同」,那比没有这个功能更糟。summarize.js 只依赖 dsh-llm 与 lang.js, -// 不反向依赖 dream.js,无循环引用。 -import { isInPeakWindow, nextOffPeakAt } from "./summarize.js"; +// Issue #239(第 4 项)镜像到巩固:错峰时段解析与「最近的高峰结束时刻」从独立的 +// 零依赖模块 peak-hours.js 取(该模块从 summarize.js 抽出,PR #320 review)—— +// 不另写一份解析器,两份实现漂移会让「同一个时段串在两处行为不同」,那比没有 +// 这个功能更糟。此前直接 import summarize.js,#316 后 summarize 反向依赖 dream +// (withEffortFallback 复用),会成真循环,故抽模块。 +import { isInPeakWindow, nextOffPeakAt } from "./peak-hours.js"; import { createHash, randomUUID } from "node:crypto"; import { STR, langOf } from "./lang.js"; export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure, resolveDreamEffort, resolveRoute }; diff --git a/dsh-mneme/lib/peak-hours.js b/dsh-mneme/lib/peak-hours.js new file mode 100644 index 00000000..8fa7f192 --- /dev/null +++ b/dsh-mneme/lib/peak-hours.js @@ -0,0 +1,117 @@ +// 高峰时段(peak hours)解析与判定——纯函数模块,零依赖。 +// +// 从 summarize.js 抽出(PR #320 review):#316 之后 summarize.js 反向依赖 +// dream.js(withEffortFallback 复用),dream 镜像错峰时再 import summarize +// 会成真循环。时段调度本来就不是蒸馏的私有语义,独立成模块后两侧都从这里 +// import,循环消失。re-export 兼容留在 summarize.js,调用方零改动。 +// +// Issue #239(第 4 项,错峰队列):高峰时段解析与判定。纯函数、可单测——排程 +// 判断不绑死真实时钟,测试才能确定性地覆盖跨零点、多段、星期过滤与非法写法。 +// spec 语法:`[<星期> ]<时段>[,<时段>...]`,星期前缀可省(省 = 每天): +// "09:00-18:00" 每天 09:00-18:00 +// "mon-fri 08:00-12:00,14:00-18:00" 工作日两段(ISO 1=周一…7=周日,也认 mon..sun) +// "sat,sun 23:00-06:00" 周末跨零点段 +// 时间取宿主本地时区。任一写法非法 → 整串视为未配置(返回 null):排程是省钱手段, +// 绝不该因为写错格式把蒸馏/巩固停掉。 +const DAY_NAMES = { mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sun: 7 }; + +function parseDayToken(token) { + const days = new Set(); + const normalize = (value) => (/^\d$/.test(value) ? Number(value) : DAY_NAMES[value] ?? null); + for (const piece of token.split(",")) { + const matched = /^([a-z]{3}|\d)(?:-([a-z]{3}|\d))?$/.exec(piece.trim().toLowerCase()); + if (!matched) return null; + const from = normalize(matched[1]); + const to = matched[2] === undefined ? from : normalize(matched[2]); + if (from === null || to === null || from < 1 || from > 7 || to < 1 || to > 7) return null; + // 支持跨周环绕(fri-mon):从 from 起逐天推进到 to,最多绕一圈。 + for (let day = from; ; day = (day % 7) + 1) { + days.add(day); + if (day === to) break; + } + } + return days.size > 0 ? [...days].sort((a, b) => a - b) : null; +} + +/** JS 的 getDay() 是 0=周日…6=周六;这里统一成 ISO(1=周一…7=周日)。 */ +function isoDay(date) { + const day = date.getDay(); + return day === 0 ? 7 : day; +} + +export function parsePeakSpec(spec) { + if (typeof spec !== "string" || spec.trim() === "") return null; + let rest = spec.trim(); + let days = null; + // 星期前缀 = 第一个空白之前的部分,但**头段含冒号就不是前缀**(那是时段本身, + // 例如 "09:00-12:00, 14:00-18:00" 里的逗号空格)。前缀解析失败一律按未配置处理, + // 不做猜测——宁可不省,也不能误停。 + const sep = rest.search(/\s/); + if (sep > 0) { + const head = rest.slice(0, sep); + if (!head.includes(":")) { + days = parseDayToken(head); + if (days === null) return null; + rest = rest.slice(sep).trim(); + } + } + const windows = []; + for (const part of rest.split(",")) { + const matched = /^\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s*$/.exec(part); + if (!matched) return null; + const [sh, sm, eh, em] = [Number(matched[1]), Number(matched[2]), Number(matched[3]), Number(matched[4])]; + if (sh > 23 || eh > 23 || sm > 59 || em > 59) return null; + const start = sh * 60 + sm; + const end = eh * 60 + em; + if (start === end) return null; + windows.push({ start, end }); + } + return windows.length > 0 ? { days, windows } : null; +} + +/** + * 该时刻是否落在高峰内。跨零点段(start > end)按「窗口所属的那一天」认星期: + * `mon-fri 23:00-06:00` 的周六 02:00 属于周五开的那个窗口,仍算高峰。 + */ +export function isInPeakWindow(date, spec) { + const parsed = parsePeakSpec(spec); + if (!parsed) return false; + const minutes = date.getHours() * 60 + date.getMinutes(); + const today = isoDay(date); + const yesterday = today === 1 ? 7 : today - 1; + const allowed = (day) => !parsed.days || parsed.days.includes(day); + return parsed.windows.some(({ start, end }) => { + if (start < end) return minutes >= start && minutes < end && allowed(today); + return (minutes >= start && allowed(today)) || (minutes < end && allowed(yesterday)); + }); +} + +/** + * 高峰内则返回「距当前最近的一个高峰结束时刻」(择时补跑用),否则 null。 + * 落在多个时段重叠处时取最早结束的那个——早跑不亏,晚跑才亏。 + */ +export function nextOffPeakAt(date, spec) { + const parsed = parsePeakSpec(spec); + if (!parsed) return null; + const minutes = date.getHours() * 60 + date.getMinutes(); + const today = isoDay(date); + const yesterday = today === 1 ? 7 : today - 1; + const allowed = (day) => !parsed.days || parsed.days.includes(day); + let bestDelta = null; + const consider = (delta) => { + if (bestDelta === null || delta < bestDelta) bestDelta = delta; + }; + for (const { start, end } of parsed.windows) { + if (start < end) { + if (minutes >= start && minutes < end && allowed(today)) consider(end - minutes); + continue; + } + if (minutes >= start && allowed(today)) consider((1440 - minutes) + end); + else if (minutes < end && allowed(yesterday)) consider(end - minutes); + } + if (bestDelta === null) return null; + const at = new Date(date.getTime()); + at.setSeconds(0, 0); + at.setMinutes(at.getMinutes() + bestDelta); + return at; +} diff --git a/dsh-mneme/lib/summarize.js b/dsh-mneme/lib/summarize.js index 7ea16494..f2273f10 100644 --- a/dsh-mneme/lib/summarize.js +++ b/dsh-mneme/lib/summarize.js @@ -260,117 +260,14 @@ function enqueueDistill(task, intervalMs = 0) { return run; } -// Issue #239(第 4 项,错峰队列):高峰时段解析与判定。纯函数、可单测——排程判断 -// 不绑死真实时钟,测试才能确定性地覆盖跨零点、多段、星期过滤与非法写法。 -// spec 语法:`[<星期> ]<时段>[,<时段>...]`,星期前缀可省(省 = 每天): -// "09:00-18:00" 每天 09:00-18:00 -// "mon-fri 08:00-12:00,14:00-18:00" 工作日两段(ISO 1=周一…7=周日,也认 mon..sun) -// "sat,sun 23:00-06:00" 周末跨零点段 -// 时间取宿主本地时区。任一写法非法 → 整串视为未配置(返回 null):排程是省钱手段, -// 绝不该因为写错格式把蒸馏停掉。 -const DAY_NAMES = { mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sun: 7 }; - -function parseDayToken(token) { - const days = new Set(); - const normalize = (value) => (/^\d$/.test(value) ? Number(value) : DAY_NAMES[value] ?? null); - for (const piece of token.split(",")) { - const matched = /^([a-z]{3}|\d)(?:-([a-z]{3}|\d))?$/.exec(piece.trim().toLowerCase()); - if (!matched) return null; - const from = normalize(matched[1]); - const to = matched[2] === undefined ? from : normalize(matched[2]); - if (from === null || to === null || from < 1 || from > 7 || to < 1 || to > 7) return null; - // 支持跨周环绕(fri-mon):从 from 起逐天推进到 to,最多绕一圈。 - for (let day = from; ; day = (day % 7) + 1) { - days.add(day); - if (day === to) break; - } - } - return days.size > 0 ? [...days].sort((a, b) => a - b) : null; -} - -/** JS 的 getDay() 是 0=周日…6=周六;这里统一成 ISO(1=周一…7=周日)。 */ -function isoDay(date) { - const day = date.getDay(); - return day === 0 ? 7 : day; -} - -export function parsePeakSpec(spec) { - if (typeof spec !== "string" || spec.trim() === "") return null; - let rest = spec.trim(); - let days = null; - // 星期前缀 = 第一个空白之前的部分,但**头段含冒号就不是前缀**(那是时段本身, - // 例如 "09:00-12:00, 14:00-18:00" 里的逗号空格)。前缀解析失败一律按未配置处理, - // 不做猜测——宁可不省,也不能误停。 - const sep = rest.search(/\s/); - if (sep > 0) { - const head = rest.slice(0, sep); - if (!head.includes(":")) { - days = parseDayToken(head); - if (days === null) return null; - rest = rest.slice(sep).trim(); - } - } - const windows = []; - for (const part of rest.split(",")) { - const matched = /^\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s*$/.exec(part); - if (!matched) return null; - const [sh, sm, eh, em] = [Number(matched[1]), Number(matched[2]), Number(matched[3]), Number(matched[4])]; - if (sh > 23 || eh > 23 || sm > 59 || em > 59) return null; - const start = sh * 60 + sm; - const end = eh * 60 + em; - if (start === end) return null; - windows.push({ start, end }); - } - return windows.length > 0 ? { days, windows } : null; -} - -/** - * 该时刻是否落在高峰内。跨零点段(start > end)按「窗口所属的那一天」认星期: - * `mon-fri 23:00-06:00` 的周六 02:00 属于周五开的那个窗口,仍算高峰。 - */ -export function isInPeakWindow(date, spec) { - const parsed = parsePeakSpec(spec); - if (!parsed) return false; - const minutes = date.getHours() * 60 + date.getMinutes(); - const today = isoDay(date); - const yesterday = today === 1 ? 7 : today - 1; - const allowed = (day) => !parsed.days || parsed.days.includes(day); - return parsed.windows.some(({ start, end }) => { - if (start < end) return minutes >= start && minutes < end && allowed(today); - return (minutes >= start && allowed(today)) || (minutes < end && allowed(yesterday)); - }); -} - -/** - * 高峰内则返回「距当前最近的一个高峰结束时刻」(择时补跑用),否则 null。 - * 落在多个时段重叠处时取最早结束的那个——早跑不亏,晚跑才亏。 - */ -export function nextOffPeakAt(date, spec) { - const parsed = parsePeakSpec(spec); - if (!parsed) return null; - const minutes = date.getHours() * 60 + date.getMinutes(); - const today = isoDay(date); - const yesterday = today === 1 ? 7 : today - 1; - const allowed = (day) => !parsed.days || parsed.days.includes(day); - let bestDelta = null; - const consider = (delta) => { - if (bestDelta === null || delta < bestDelta) bestDelta = delta; - }; - for (const { start, end } of parsed.windows) { - if (start < end) { - if (minutes >= start && minutes < end && allowed(today)) consider(end - minutes); - continue; - } - if (minutes >= start && allowed(today)) consider((1440 - minutes) + end); - else if (minutes < end && allowed(yesterday)) consider(end - minutes); - } - if (bestDelta === null) return null; - const at = new Date(date.getTime()); - at.setSeconds(0, 0); - at.setMinutes(at.getMinutes() + bestDelta); - return at; -} - +// Issue #239(第 4 项,错峰队列):高峰时段解析与判定已抽到独立零依赖模块 +// src/peak-hours.js(PR #320 review:#316 后 summarize 反向依赖 dream,dream 镜像 +// 错峰再 import 本文件会成真循环;时段调度本就不是蒸馏私有语义)。这里 re-export +// 兼容既有调用方与测试(test/summarize.test.js 从本文件 import),零改动。 +// 注意 re-export 不引入本地作用域——本文件 331/387 行仍调用这两个函数, +// import + re-export 两行都要。 +import { parsePeakSpec, isInPeakWindow, nextOffPeakAt } from "./peak-hours.js"; +export { parsePeakSpec, isInPeakWindow, nextOffPeakAt }; export function createSummarizer(ctx, service, config, deps = {}) { if (!config.autoSummarize) return { dispose: () => {} }; diff --git a/dsh-mneme/src/dream.js b/dsh-mneme/src/dream.js index d3d472b1..8ddc37c2 100644 --- a/dsh-mneme/src/dream.js +++ b/dsh-mneme/src/dream.js @@ -2,11 +2,12 @@ import { validateDecisions, applyDecisions } from "./dream/decisions.js"; import { clusterMemories, findPotentialConflicts, cosineSimilarity } from "./dream/clustering.js"; import { clusterByTag, intersectEvidence } from "./dream/narratives.js"; import { scopeKeyOf } from "./scope.js"; -// Issue #239(第 4 项)镜像到巩固:错峰时段解析与「最近的高峰结束时刻」直接复用 -// 蒸馏侧已导出的纯函数,不另写一份解析器——两份实现漂移会让「同一个时段串在两处 -// 行为不同」,那比没有这个功能更糟。summarize.js 只依赖 dsh-llm 与 lang.js, -// 不反向依赖 dream.js,无循环引用。 -import { isInPeakWindow, nextOffPeakAt } from "./summarize.js"; +// Issue #239(第 4 项)镜像到巩固:错峰时段解析与「最近的高峰结束时刻」从独立的 +// 零依赖模块 peak-hours.js 取(该模块从 summarize.js 抽出,PR #320 review)—— +// 不另写一份解析器,两份实现漂移会让「同一个时段串在两处行为不同」,那比没有 +// 这个功能更糟。此前直接 import summarize.js,#316 后 summarize 反向依赖 dream +// (withEffortFallback 复用),会成真循环,故抽模块。 +import { isInPeakWindow, nextOffPeakAt } from "./peak-hours.js"; import { createHash, randomUUID } from "node:crypto"; import { STR, langOf } from "./lang.js"; export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure, resolveDreamEffort, resolveRoute }; diff --git a/dsh-mneme/src/peak-hours.js b/dsh-mneme/src/peak-hours.js new file mode 100644 index 00000000..8fa7f192 --- /dev/null +++ b/dsh-mneme/src/peak-hours.js @@ -0,0 +1,117 @@ +// 高峰时段(peak hours)解析与判定——纯函数模块,零依赖。 +// +// 从 summarize.js 抽出(PR #320 review):#316 之后 summarize.js 反向依赖 +// dream.js(withEffortFallback 复用),dream 镜像错峰时再 import summarize +// 会成真循环。时段调度本来就不是蒸馏的私有语义,独立成模块后两侧都从这里 +// import,循环消失。re-export 兼容留在 summarize.js,调用方零改动。 +// +// Issue #239(第 4 项,错峰队列):高峰时段解析与判定。纯函数、可单测——排程 +// 判断不绑死真实时钟,测试才能确定性地覆盖跨零点、多段、星期过滤与非法写法。 +// spec 语法:`[<星期> ]<时段>[,<时段>...]`,星期前缀可省(省 = 每天): +// "09:00-18:00" 每天 09:00-18:00 +// "mon-fri 08:00-12:00,14:00-18:00" 工作日两段(ISO 1=周一…7=周日,也认 mon..sun) +// "sat,sun 23:00-06:00" 周末跨零点段 +// 时间取宿主本地时区。任一写法非法 → 整串视为未配置(返回 null):排程是省钱手段, +// 绝不该因为写错格式把蒸馏/巩固停掉。 +const DAY_NAMES = { mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sun: 7 }; + +function parseDayToken(token) { + const days = new Set(); + const normalize = (value) => (/^\d$/.test(value) ? Number(value) : DAY_NAMES[value] ?? null); + for (const piece of token.split(",")) { + const matched = /^([a-z]{3}|\d)(?:-([a-z]{3}|\d))?$/.exec(piece.trim().toLowerCase()); + if (!matched) return null; + const from = normalize(matched[1]); + const to = matched[2] === undefined ? from : normalize(matched[2]); + if (from === null || to === null || from < 1 || from > 7 || to < 1 || to > 7) return null; + // 支持跨周环绕(fri-mon):从 from 起逐天推进到 to,最多绕一圈。 + for (let day = from; ; day = (day % 7) + 1) { + days.add(day); + if (day === to) break; + } + } + return days.size > 0 ? [...days].sort((a, b) => a - b) : null; +} + +/** JS 的 getDay() 是 0=周日…6=周六;这里统一成 ISO(1=周一…7=周日)。 */ +function isoDay(date) { + const day = date.getDay(); + return day === 0 ? 7 : day; +} + +export function parsePeakSpec(spec) { + if (typeof spec !== "string" || spec.trim() === "") return null; + let rest = spec.trim(); + let days = null; + // 星期前缀 = 第一个空白之前的部分,但**头段含冒号就不是前缀**(那是时段本身, + // 例如 "09:00-12:00, 14:00-18:00" 里的逗号空格)。前缀解析失败一律按未配置处理, + // 不做猜测——宁可不省,也不能误停。 + const sep = rest.search(/\s/); + if (sep > 0) { + const head = rest.slice(0, sep); + if (!head.includes(":")) { + days = parseDayToken(head); + if (days === null) return null; + rest = rest.slice(sep).trim(); + } + } + const windows = []; + for (const part of rest.split(",")) { + const matched = /^\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s*$/.exec(part); + if (!matched) return null; + const [sh, sm, eh, em] = [Number(matched[1]), Number(matched[2]), Number(matched[3]), Number(matched[4])]; + if (sh > 23 || eh > 23 || sm > 59 || em > 59) return null; + const start = sh * 60 + sm; + const end = eh * 60 + em; + if (start === end) return null; + windows.push({ start, end }); + } + return windows.length > 0 ? { days, windows } : null; +} + +/** + * 该时刻是否落在高峰内。跨零点段(start > end)按「窗口所属的那一天」认星期: + * `mon-fri 23:00-06:00` 的周六 02:00 属于周五开的那个窗口,仍算高峰。 + */ +export function isInPeakWindow(date, spec) { + const parsed = parsePeakSpec(spec); + if (!parsed) return false; + const minutes = date.getHours() * 60 + date.getMinutes(); + const today = isoDay(date); + const yesterday = today === 1 ? 7 : today - 1; + const allowed = (day) => !parsed.days || parsed.days.includes(day); + return parsed.windows.some(({ start, end }) => { + if (start < end) return minutes >= start && minutes < end && allowed(today); + return (minutes >= start && allowed(today)) || (minutes < end && allowed(yesterday)); + }); +} + +/** + * 高峰内则返回「距当前最近的一个高峰结束时刻」(择时补跑用),否则 null。 + * 落在多个时段重叠处时取最早结束的那个——早跑不亏,晚跑才亏。 + */ +export function nextOffPeakAt(date, spec) { + const parsed = parsePeakSpec(spec); + if (!parsed) return null; + const minutes = date.getHours() * 60 + date.getMinutes(); + const today = isoDay(date); + const yesterday = today === 1 ? 7 : today - 1; + const allowed = (day) => !parsed.days || parsed.days.includes(day); + let bestDelta = null; + const consider = (delta) => { + if (bestDelta === null || delta < bestDelta) bestDelta = delta; + }; + for (const { start, end } of parsed.windows) { + if (start < end) { + if (minutes >= start && minutes < end && allowed(today)) consider(end - minutes); + continue; + } + if (minutes >= start && allowed(today)) consider((1440 - minutes) + end); + else if (minutes < end && allowed(yesterday)) consider(end - minutes); + } + if (bestDelta === null) return null; + const at = new Date(date.getTime()); + at.setSeconds(0, 0); + at.setMinutes(at.getMinutes() + bestDelta); + return at; +} diff --git a/dsh-mneme/src/summarize.js b/dsh-mneme/src/summarize.js index 7ea16494..f2273f10 100644 --- a/dsh-mneme/src/summarize.js +++ b/dsh-mneme/src/summarize.js @@ -260,117 +260,14 @@ function enqueueDistill(task, intervalMs = 0) { return run; } -// Issue #239(第 4 项,错峰队列):高峰时段解析与判定。纯函数、可单测——排程判断 -// 不绑死真实时钟,测试才能确定性地覆盖跨零点、多段、星期过滤与非法写法。 -// spec 语法:`[<星期> ]<时段>[,<时段>...]`,星期前缀可省(省 = 每天): -// "09:00-18:00" 每天 09:00-18:00 -// "mon-fri 08:00-12:00,14:00-18:00" 工作日两段(ISO 1=周一…7=周日,也认 mon..sun) -// "sat,sun 23:00-06:00" 周末跨零点段 -// 时间取宿主本地时区。任一写法非法 → 整串视为未配置(返回 null):排程是省钱手段, -// 绝不该因为写错格式把蒸馏停掉。 -const DAY_NAMES = { mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sun: 7 }; - -function parseDayToken(token) { - const days = new Set(); - const normalize = (value) => (/^\d$/.test(value) ? Number(value) : DAY_NAMES[value] ?? null); - for (const piece of token.split(",")) { - const matched = /^([a-z]{3}|\d)(?:-([a-z]{3}|\d))?$/.exec(piece.trim().toLowerCase()); - if (!matched) return null; - const from = normalize(matched[1]); - const to = matched[2] === undefined ? from : normalize(matched[2]); - if (from === null || to === null || from < 1 || from > 7 || to < 1 || to > 7) return null; - // 支持跨周环绕(fri-mon):从 from 起逐天推进到 to,最多绕一圈。 - for (let day = from; ; day = (day % 7) + 1) { - days.add(day); - if (day === to) break; - } - } - return days.size > 0 ? [...days].sort((a, b) => a - b) : null; -} - -/** JS 的 getDay() 是 0=周日…6=周六;这里统一成 ISO(1=周一…7=周日)。 */ -function isoDay(date) { - const day = date.getDay(); - return day === 0 ? 7 : day; -} - -export function parsePeakSpec(spec) { - if (typeof spec !== "string" || spec.trim() === "") return null; - let rest = spec.trim(); - let days = null; - // 星期前缀 = 第一个空白之前的部分,但**头段含冒号就不是前缀**(那是时段本身, - // 例如 "09:00-12:00, 14:00-18:00" 里的逗号空格)。前缀解析失败一律按未配置处理, - // 不做猜测——宁可不省,也不能误停。 - const sep = rest.search(/\s/); - if (sep > 0) { - const head = rest.slice(0, sep); - if (!head.includes(":")) { - days = parseDayToken(head); - if (days === null) return null; - rest = rest.slice(sep).trim(); - } - } - const windows = []; - for (const part of rest.split(",")) { - const matched = /^\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s*$/.exec(part); - if (!matched) return null; - const [sh, sm, eh, em] = [Number(matched[1]), Number(matched[2]), Number(matched[3]), Number(matched[4])]; - if (sh > 23 || eh > 23 || sm > 59 || em > 59) return null; - const start = sh * 60 + sm; - const end = eh * 60 + em; - if (start === end) return null; - windows.push({ start, end }); - } - return windows.length > 0 ? { days, windows } : null; -} - -/** - * 该时刻是否落在高峰内。跨零点段(start > end)按「窗口所属的那一天」认星期: - * `mon-fri 23:00-06:00` 的周六 02:00 属于周五开的那个窗口,仍算高峰。 - */ -export function isInPeakWindow(date, spec) { - const parsed = parsePeakSpec(spec); - if (!parsed) return false; - const minutes = date.getHours() * 60 + date.getMinutes(); - const today = isoDay(date); - const yesterday = today === 1 ? 7 : today - 1; - const allowed = (day) => !parsed.days || parsed.days.includes(day); - return parsed.windows.some(({ start, end }) => { - if (start < end) return minutes >= start && minutes < end && allowed(today); - return (minutes >= start && allowed(today)) || (minutes < end && allowed(yesterday)); - }); -} - -/** - * 高峰内则返回「距当前最近的一个高峰结束时刻」(择时补跑用),否则 null。 - * 落在多个时段重叠处时取最早结束的那个——早跑不亏,晚跑才亏。 - */ -export function nextOffPeakAt(date, spec) { - const parsed = parsePeakSpec(spec); - if (!parsed) return null; - const minutes = date.getHours() * 60 + date.getMinutes(); - const today = isoDay(date); - const yesterday = today === 1 ? 7 : today - 1; - const allowed = (day) => !parsed.days || parsed.days.includes(day); - let bestDelta = null; - const consider = (delta) => { - if (bestDelta === null || delta < bestDelta) bestDelta = delta; - }; - for (const { start, end } of parsed.windows) { - if (start < end) { - if (minutes >= start && minutes < end && allowed(today)) consider(end - minutes); - continue; - } - if (minutes >= start && allowed(today)) consider((1440 - minutes) + end); - else if (minutes < end && allowed(yesterday)) consider(end - minutes); - } - if (bestDelta === null) return null; - const at = new Date(date.getTime()); - at.setSeconds(0, 0); - at.setMinutes(at.getMinutes() + bestDelta); - return at; -} - +// Issue #239(第 4 项,错峰队列):高峰时段解析与判定已抽到独立零依赖模块 +// src/peak-hours.js(PR #320 review:#316 后 summarize 反向依赖 dream,dream 镜像 +// 错峰再 import 本文件会成真循环;时段调度本就不是蒸馏私有语义)。这里 re-export +// 兼容既有调用方与测试(test/summarize.test.js 从本文件 import),零改动。 +// 注意 re-export 不引入本地作用域——本文件 331/387 行仍调用这两个函数, +// import + re-export 两行都要。 +import { parsePeakSpec, isInPeakWindow, nextOffPeakAt } from "./peak-hours.js"; +export { parsePeakSpec, isInPeakWindow, nextOffPeakAt }; export function createSummarizer(ctx, service, config, deps = {}) { if (!config.autoSummarize) return { dispose: () => {} };