Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions dsh-mneme/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <apiToken>` |
Expand Down
25 changes: 22 additions & 3 deletions dsh-mneme/lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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": "留空 = 用巩固模型或当前模型;建议选非思考模型",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -2222,7 +2233,11 @@ window.__ModuleLoader__.load({
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 随本版
Expand Down Expand Up @@ -2266,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" },
Expand Down
16 changes: 16 additions & 0 deletions dsh-mneme/lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)。模型分类声明:
Expand Down
134 changes: 98 additions & 36 deletions dsh-mneme/lib/dream.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +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 项)镜像到巩固:错峰时段解析与「最近的高峰结束时刻」从独立的
// 零依赖模块 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 };
Expand Down Expand Up @@ -750,8 +756,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 };
Expand All @@ -772,51 +782,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.
Expand Down
26 changes: 26 additions & 0 deletions dsh-mneme/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading
Loading