@modusensus/dsh-mneme 0.8.7 → 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.js CHANGED
@@ -87,6 +87,22 @@ export const Config = z.object({
87
87
  // 父开关 `autoInject` 关闭时它不生效(闸门见 injectChildEnabled);用户显式写进
88
88
  // feature_flags 的值永远优先于这里的默认值。
89
89
  injectGuidanceEnabled: z.boolean().default(true),
90
+ // #249 N3(压缩边缘双落点):上下文即将大幅精简前抢救「正在做什么」。默认关。
91
+ // 触发靠宿主自己的压缩事件(`compaction/start|summary|end`,都在事件白名单里),
92
+ // 不自定一套阈值参数——「压缩边缘」由宿主定义,我们再校准一份只会与之漂移。
93
+ // 为什么是「新时机 + 新表面」因而默认关:它往对话里**追加消息**(新的注入表面,
94
+ // 参照实现里最容易累积成一堆历史的那类),并多写一张提案表。按 §10 判据,引入
95
+ // 新时机/新表面/新成本的子项独立成键、默认关;只修正既有块的(基础内容分池、
96
+ // 库可见性行)才随父开关默认开。`pinnedInjectBudget` 与它同批,但属前者之外:
97
+ // 那是既有块内的预算,不是新表面。
98
+ // 双落点是硬要求,不能只留一半:宿主的压缩摘要器**只看对话里的内容**,只落库不
99
+ // 注入,等于在摘要重建里什么都没留下;只注入不落库,则压缩一过就随旧消息一起
100
+ // 消失。两者都做,且落库先于注入(注入失败不该丢提案)。
101
+ // 祖先:`autoInject`(父关则本项不生效,闸门见 injectChildEnabled)。轻量档默认
102
+ // 置关(见 LIGHT_MODE_OFF):轻量档多一份注入物是反的,与 injectGuidanceEnabled
103
+ // 同一取舍。注意预设只是默认值而非强制——装配时用户显式开关在它之后展开,勾了就赢
104
+ // (合并顺序见 index.js 装配处)。
105
+ continuityRescueEnabled: z.boolean().default(false),
90
106
  // #249(第一批):B1 pin 池预算——约束/偏好类注入条目的独立小上限。约束与
91
107
  // 偏好被静默降级是本议题的立项核心(同类知识与情景日志同池同速率摘要,实测
92
108
  // 一轮压缩后仅保 53%、五轮 10%),故这两类不进相关性竞争、不参与跨轮轮换、
@@ -129,6 +145,31 @@ export const Config = z.object({
129
145
  // 起算,失败/degraded 的 run 也占用间隔;间隔内的触发请求静默跳过,下一次
130
146
  // 写入事件会重新评估。
131
147
  dreamMinIntervalMinutes: z.natural().min(0).max(10080).default(0),
148
+ // Issue #292(#135 派生):autoDream 连续失败退避(opt-in,默认关 = 行为与
149
+ // 现状逐字节一致)。开启后调度器对连续失败做指数退避:有效最小间隔 =
150
+ // dreamMinIntervalMinutes × 2^连续失败数(成功一次清零恢复),封顶 30 分钟。
151
+ // #89 的最小间隔闸失败 run 也占用,但间隔恒定——恒定失败的模型(#135 空体
152
+ // 面)会按固定节奏连发刷爆配额;退避把下次重试按失败次数指数推远。基数取
153
+ // dreamMinIntervalMinutes:基数为 0 时无闸可翻倍,本键不自己产生间隔(先配
154
+ // dreamMinIntervalMinutes 再开本键)。与 dreamPeakHours / dreamMinIntervalMinutes
155
+ // 同族(节流阀,不新增任何 LLM 调用),故不进 LIGHT_MODE_OFF。
156
+ autoDreamFailureBackoff: z.boolean().default(false),
157
+ // Issue #239(第 4 项,错峰队列)镜像到巩固:高峰期不做梦。与
158
+ // summarizePeakHours 同一份时段语法(复用 src/summarize.js 的 parsePeakSpec /
159
+ // isInPeakWindow / nextOffPeakAt,不另写解析器):逗号分隔、可带星期前缀、支持
160
+ // 跨零点。空串 = 关闭,行为与现状逐字节一致。
161
+ // "09:00-18:00" 每天 09:00-18:00
162
+ // "mon-fri 08:00-12:00,14:00-18:00" 工作日两段(按高峰计费的供应商即此形态)
163
+ // 为什么巩固比蒸馏更该有这道闸:单次巩固的输入是整窗快照(dreamMaxSnapshotSize
164
+ // 条),实测一次 run 的 LLM 时长可达数分钟量级,撞上高峰时既贵又慢;而它由写入
165
+ // 事件触发、没有天然的「等到空闲再跑」路径。命中高峰时:不调 LLM、不刷新
166
+ // baseline(阈值继续累积,留到非高峰一次性巩固),登记一行 status='skipped' /
167
+ // error_message='peak-hours' 审计,并按下面的上限择时补跑。任一写法非法则整串
168
+ // 按「未配置」处理——排程是省钱手段,绝不该因为写错格式把巩固停掉。
169
+ dreamPeakHours: z.string().default(""),
170
+ // 高峰顺延上限(分钟,0 = 不设上限):到点仍处高峰就照常跑,避免整天高峰把巩固
171
+ // 饿死。默认 120,与 summarizePeakMaxDeferMinutes 对齐。仅在时段串非空时生效。
172
+ dreamPeakMaxDeferMinutes: z.natural().min(0).max(1440).default(120),
132
173
  // 巩固模型路由(settings panel「巩固模型」/ dreamProvider+dreamModel):
133
174
  // dream 的记忆沉淀专用 LLM 路由,显式配置优先于 agent 默认模型(config-first,
134
175
  // Issue #25)。模型分类声明:
@@ -476,6 +517,23 @@ export const Config = z.object({
476
517
  z.const("high"),
477
518
  z.const("none")
478
519
  ]).description("同 dreamReasoningEffort:sleep 各阶段 LLM 的推理档位,未配置 = 自动取模型支持的最低档;显式 'none' = 不发送字段、用服务商自带默认。"),
520
+ // Pass-through reasoning effort for the distill (summarize) LLM call
521
+ // (issue #315). Mirrors entityExtractionReasoning rather than dream: the
522
+ // default 'none' omits the field entirely (provider default), so current
523
+ // behavior is unchanged until the user opts in — no auto-lowest resolution
524
+ // here, distill failures are retried at the window level anyway. off/low/
525
+ // medium/high are forwarded verbatim; a provider that rejects the effort
526
+ // retries once without it (withEffortFallback), so opting in is safe to
527
+ // experiment with. The failure being addressed is the #9 shape on the
528
+ // distill path: a thinking model drains the output budget on reasoning and
529
+ // the summary comes back empty/truncated.
530
+ summarizeReasoningEffort: z.union([
531
+ z.const("off"),
532
+ z.const("low"),
533
+ z.const("medium"),
534
+ z.const("high"),
535
+ z.const("none")
536
+ ]).default("none").description("蒸馏(会话总结提炼)LLM 的推理档位:默认 'none' = 不发送字段、用服务商自带默认;off/low/medium/high 原样传递,被模型拒收时自动去掉字段重试一次(与巩固/睡眠同款降级,#315)。思考型模型建议 off/low,避免推理烧光输出预算。"),
479
537
  // Issue #257:sleep 冲突/模式两阶段的输出预算(原硬编码 2048)。实测默认档
480
538
  // 每对裁决约 90 token、24 对 2097——2048 恰好压在边界(53 次运行 48 败);
481
539
  // full 档六分支实测约 290 token/对、24 对 6967,2048 必然截断。默认 8192
@@ -633,7 +691,12 @@ const LIGHT_MODE_OFF = [
633
691
  // 轻量模式不开 document 指针行(#230,opt-in:注册/注入/检索增强全随闸)。
634
692
  "documentMemoryEnabled",
635
693
  // 轻量模式不开热计算(heat 属于重型增强;关掉后 sleep 降级也退回纯时间分层)。
636
- "heatEnabled"
694
+ "heatEnabled",
695
+ // #249 N3:轻量档默认不开压缩边缘双落点——它往对话里追加消息(新的注入表面),
696
+ // 轻量档(小模型 / 小上下文)最不该再多一份注入物。这是预设给的默认值、不是强制:
697
+ // 用户显式勾选仍然赢(合并顺序「用户开关 > 轻量预设 > bundle 配置」,同
698
+ // injectGuidanceEnabled)。
699
+ "continuityRescueEnabled"
637
700
  ];
638
701
 
639
702
  /**
@@ -670,7 +733,7 @@ export function applyLightModePreset(cfg) {
670
733
  * 是扁平键,不受这条限制。
671
734
  */
672
735
  export const INJECT_CHILD_FLAGS = Object.freeze({
673
- autoInject: Object.freeze(["injectGuidanceEnabled"])
736
+ autoInject: Object.freeze(["injectGuidanceEnabled", "continuityRescueEnabled"])
674
737
  });
675
738
 
676
739
  /** 子开关的运行时生效值:父开关显式关(false)时恒不生效。 */
@@ -0,0 +1,220 @@
1
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
2
+
3
+ // #249 N3(压缩边缘双落点):上下文即将大幅精简前,抢救「正在做什么」。
4
+ //
5
+ // 为什么是这个形状(#249 §4.4 / §6.2 / §11;维护者 2026-09-24 拍板「都同意,开工」):
6
+ //
7
+ // 1. **必须双落点**。宿主的压缩摘要器只看对话里的内容:压缩时宿主按固定字段模板
8
+ // 重建对话,只放系统提示段的东西不进这次重建,等于白写。所以除了落库,还要把同
9
+ // 一条连续性快照追加成靠近序列末尾的消息。DSH 没有 in-memory 消息改写钩子
10
+ // (§7 已核),因此这是「注入 + 序号引用寻址」,不是改写历史。
11
+ // 2. **落库落成提案,不落成记忆行**。边缘产出直接进 memories,长会话会攒出第 N 条
12
+ // 同主题条目(#275 记的失败形态);转正通道与 #254 的二次确认共用一套、阶段二才
13
+ // 打开,本批只写 pending 行。
14
+ // 3. **全程不调模型**。三个字段是确定性抽取 + 截断(口径见 deriveContinuity),判定
15
+ // 也只用本地字符串比较;门控成本为零。
16
+ // 4. **触发器就是宿主真的压缩**,不自定一套阈值。宿主在 `agent/pre-step`(压力)与
17
+ // `agent/request-error`(溢出)两处触发压缩,我们只订阅压缩事件
18
+ // (`compaction/start|summary|end`,都在 @deepseek-ai/dsh-session 的事件白名单里
19
+ // `lib/types/known-event-types.js:31-34`)。自定阈值会与宿主口径漂移,还多一份没
20
+ // 人校准的参数;「压缩边缘」本来就有宿主自己下的定义。
21
+ //
22
+ // 落点顺序(为什么 pre-step 里挂得住):宿主的压缩插件在它自己的 pre-step 监听器里先
23
+ // `await compactIfNeeded(...)` 再 `return next()`,所以我们的监听器拿到 `next()` 结果
24
+ // 时压缩事件已经落库;而我们返回的 `decision.messages` 由宿主在本步末尾以
25
+ // `surfaceOp: "append"` 追加(`@deepseek-ai/dsh-agent-loop/lib/index.js:1046`),晚于
26
+ // 压缩的 `surfaceOp: "replace"`(`dsh-compaction-basic/lib/index.js:649`)——落点天然
27
+ // 在压缩之后、靠近序列末尾。两条注册顺序都成立(谁先谁后,我们的 `await next()` 都在
28
+ // 压缩工作之后返回)。
29
+
30
+ /** 提案的 kind(唯一键的一半):一次会话里"压缩边缘"这一类只留一条。 */
31
+ export const CONTINUITY_KIND = "compaction-edge";
32
+ /** 注入消息的统一前缀:机械校验与「不变不重复」判重都靠它(#249 §6.3)。 */
33
+ export const CONTINUITY_NOTICE_PREFIX = "[dsh-mneme continuity]";
34
+ /** 单字段字符上限:抽取是截断,不是摘要(改口径不需要动调用方)。 */
35
+ export const CONTINUITY_FIELD_MAX = 200;
36
+ /** 整条注入消息的字符上限。比 §6.3 的 160B 单行提醒宽:它是结构化快照,不是一句话提醒。 */
37
+ export const CONTINUITY_NOTICE_MAX = 900;
38
+ /** 边缘表的兜底上限:只留序号、且有界——压缩完就没有下一次 pre-step 的会话不走消费路径。 */
39
+ const MAX_TRACKED_EDGES = 50;
40
+
41
+ const EDGE_EVENT_TYPES = new Set(["compaction/start", "compaction/summary", "compaction/end"]);
42
+
43
+ // 与 summarize.js 的 collectMessages 同一个口径:只取公开文本块,reasoning 私有推理
44
+ // 不进快照(快照可能被宿主重建进摘要)。刻意副本而不是导出——那边是会话蒸馏的内部
45
+ // 细节,两边取文本的理由不同。
46
+ function textOf(content) {
47
+ if (typeof content === "string") return content;
48
+ if (!Array.isArray(content)) return "";
49
+ return content
50
+ .map((block) => (typeof block === "string" ? block : (block?.type === "text" && typeof block.text === "string" ? block.text : "")))
51
+ .filter((s) => s)
52
+ .join("\n");
53
+ }
54
+
55
+ // DSH 0.1.2-rc.1 起 Session 用 snapshotEvents(),兼容旧版 .events。这是**全量日志**
56
+ // (append-only,含已被 surfaceOp:"replace" 折叠出表面的旧事件)——抽取要的正是全量:
57
+ // 被压缩折叠掉的最后一条真实用户消息,也曾是"正在做什么"。
58
+ function sessionEvents(session) {
59
+ let events = typeof session?.snapshotEvents === "function" ? session.snapshotEvents() : session?.events;
60
+ // 与 summarize.js 的同名兼容层同形:snapshotEvents 不可用或返回非数组时退回 .events。
61
+ if (!Array.isArray(events)) events = session?.events;
62
+ return Array.isArray(events) ? events : [];
63
+ }
64
+
65
+ // 「当前表面」= `session.surface.nodes`(只存活节点的 seq,事件体在上面那份日志里)。
66
+ // 判重必须按表面而不是全量日志:旧注入还在日志里、但已被折叠出表面,照日志判重会让
67
+ // 这一次压缩后该补的注入被静默跳过——双落点的 ② 就空了一次,而日志里看不出发生过。
68
+ // 宿主没有表面投影(旧版)时退回全量:宁可少追加一次,也不在表面里重复追加同一份文本。
69
+ function surfaceEvents(session) {
70
+ const events = sessionEvents(session);
71
+ let nodes;
72
+ // 宿主的 `surface.nodes` 是会抛的 getter(它自己用过、之后又被移除或替换的 message
73
+ // projection 会让宿主内部的 `_assertProjections` 抛)。判重是锦上添花的一步:读不到就
74
+ // 退回全量,别让一个可选的抢救功能因为读宿主状态失败而把 agent 的这一步变失败。
75
+ try {
76
+ nodes = session?.surface?.nodes;
77
+ } catch {
78
+ return events;
79
+ }
80
+ if (!Array.isArray(nodes)) return events;
81
+ const inSurface = new Set(nodes);
82
+ return events.filter((event) => inSurface.has(event?.seq));
83
+ }
84
+
85
+ /**
86
+ * 抽取连续性三字段(纯函数、无 LLM、无副作用,供测试直接钉住口径):
87
+ * - `currentWork`:最近一条**真实** user/message(插件注入与子代理上报不算用户指令),
88
+ * 取头部 CONTINUITY_FIELD_MAX 字符——"正在做什么"就是最后那条指令。
89
+ * - `nextStep`:最近一条 assistant/message 的**尾部**同长度——"下一步"活在末尾那
90
+ * 一段/最后一句里,取头会把开场白带进来。
91
+ * - `openQuestions`:恒 null。确定性抽取判不出「哪些问题还没解决」,硬用反引号/问号
92
+ * 猜会给出似是而非的字段;这个字段留给转正通道与人工补(不是所有字段都必须自动
93
+ * 填满——宁可空着,也不编造)。空字段在注入文本里如实标 (none)。
94
+ */
95
+ export function deriveContinuity(session, fieldMax = CONTINUITY_FIELD_MAX) {
96
+ let currentWork = null;
97
+ let nextStep = null;
98
+ for (const event of sessionEvents(session)) {
99
+ const data = event?.data ?? {};
100
+ if (event?.type === "user/message") {
101
+ // 只认用户自己写的:source.kind 缺省(旧事件)或 "user"。插件消息(我们自己的
102
+ // 注入、其它插件)与子代理上报都不是"当前指令"。
103
+ const kind = data?.source?.kind;
104
+ if (kind !== undefined && kind !== "user") continue;
105
+ const text = textOf(data.content).trim();
106
+ if (text) currentWork = text.slice(0, fieldMax);
107
+ } else if (event?.type === "assistant/message") {
108
+ const text = textOf(data?.message?.content).trim();
109
+ if (text) nextStep = text.slice(-fieldMax);
110
+ }
111
+ }
112
+ return { currentWork, nextStep, openQuestions: null };
113
+ }
114
+
115
+ /** 单行化:字段里的换行会把「靠近末尾的一条消息」摊成多行,也破坏 §6.3 的单行约定。 */
116
+ function oneLine(text) {
117
+ return typeof text === "string" ? text.replace(/\s*\n\s*/g, " ").trim() : "";
118
+ }
119
+
120
+ /** 渲染注入文本(纯函数):统一前缀 + 三个固定字段(#249 §6.2)。 */
121
+ export function renderContinuityNotice(fields, noticeMax = CONTINUITY_NOTICE_MAX) {
122
+ const body = [
123
+ `${CONTINUITY_NOTICE_PREFIX} Context compaction happened in this session; here is the continuity snapshot ` +
124
+ "recorded so it survives outside the transcript (a proposal — not yet in the memory store):",
125
+ `current_work: ${oneLine(fields?.currentWork) || "(none)"}`,
126
+ `next_step: ${oneLine(fields?.nextStep) || "(none)"}`,
127
+ `open_questions: ${oneLine(fields?.openQuestions) || "(none)"}`
128
+ ].join("\n");
129
+ return body.length > noticeMax ? body.slice(0, noticeMax) : body;
130
+ }
131
+
132
+ /** 表面上是否已经有同一条注入(「不变不重复」,#249 §6.5):按前缀 + 全文比较,
133
+ * 不做语义判定——没有改写钩子,能做的只有"同一份文本不追加第二次"。 */
134
+ function noticeAlreadyInSurface(session, text) {
135
+ return surfaceEvents(session).some((event) => {
136
+ if (event?.type !== "user/message") return false;
137
+ if (event?.data?.source?.plugin !== "dsh-mneme") return false;
138
+ const content = textOf(event?.data?.content);
139
+ return content === text;
140
+ });
141
+ }
142
+
143
+ /**
144
+ * 挂两条监听(#249 §7 的 `agent/pre-step` 触发位 + 压缩事件面),返回 { dispose }。
145
+ *
146
+ * `store` 直接注入:本模块只写提案表、不碰检索/嵌入,没有用到 service 的任何能力。
147
+ * 调用方负责父/子闸门(`autoInject` + `continuityRescueEnabled`);本函数不做开关判定,
148
+ * 保持"挂上就是开"的单一口径。
149
+ */
150
+ export function createContinuityRescue(ctx, store) {
151
+ // 待处理的边缘:sessionId → 压缩事件序号。压缩事件到达时置位,下一次 pre-step 消费后
152
+ // 删除(同一个边缘绝不注入第二次)。只留序号、不留 Session:插件在宿主进程里只 apply
153
+ // 一次、服务所有会话,握着 Session 就是握着它的全量日志不放——压缩完就收工、不再有
154
+ // pre-step 是常见路径,这些条目永远走不到消费路径上。
155
+ const edge = new Map();
156
+
157
+ const unsubscribe = ctx.on("session/event", (session, event) => {
158
+ if (!EDGE_EVENT_TYPES.has(event?.type)) return;
159
+ if (!session?.id) return;
160
+ if (!edge.has(session.id) && edge.size >= MAX_TRACKED_EDGES) {
161
+ // 兜底淘汰最旧的(Map 是插入序):这里漏掉最坏也只少一次注入,不会无界增长。
162
+ edge.delete(edge.keys().next().value);
163
+ }
164
+ // 同一轮压缩会依次落 start/summary/end,后到的覆盖前一个:留着最后一个(更有
165
+ // 信息——summary 带摘要事件 seq),序号即触发证据。
166
+ edge.set(session.id, typeof event.seq === "number" ? event.seq : null);
167
+ });
168
+
169
+ const offStep = ctx.on("agent/pre-step", async (payload, next) => {
170
+ const decision = await next();
171
+ // reject 是"这一步不该发生",往里面塞消息没有意义;未知形状原样放行。
172
+ if (decision?.kind !== "enter") return decision;
173
+ const session = payload?.agent?.session;
174
+ const sessionId = session?.id;
175
+ if (!sessionId) return decision;
176
+ if (!edge.has(sessionId)) return decision;
177
+ const edgeSeq = edge.get(sessionId);
178
+ edge.delete(sessionId);
179
+
180
+ // 抽取就地用 payload 上的这个 session(与置位时同一个 id),不从边缘表里握对象引用。
181
+ const fields = deriveContinuity(session);
182
+ // 落库先于注入:提案是"脱离对话独立存活"的那一半。写不进去不算宿主这一步的失败——
183
+ // 可选插件的可选能力把 agent 的一步带崩,比丢一次抢救严重得多(本函数的不变量)。
184
+ try {
185
+ const saved = store.saveContinuityProposal({
186
+ sessionId,
187
+ kind: CONTINUITY_KIND,
188
+ currentWork: fields.currentWork,
189
+ nextStep: fields.nextStep,
190
+ openQuestions: fields.openQuestions,
191
+ edgeSeq
192
+ });
193
+ // 弃新是"静默失效"的那一类:不报出来,运维与 §8 的触发率口径都会把它当成没触发。
194
+ if (saved?.dropped) {
195
+ ctx.logger?.warn?.(`[dsh-mneme] continuity: pending queue full, dropped the edge for session ${sessionId}`);
196
+ }
197
+ } catch (error) {
198
+ ctx.logger?.warn?.(`[dsh-mneme] continuity proposal not recorded: ${String(error)}`);
199
+ }
200
+
201
+ const text = renderContinuityNotice(fields);
202
+ if (noticeAlreadyInSurface(session, text)) return decision;
203
+ const messages = Array.isArray(decision.messages) ? decision.messages : [];
204
+ return {
205
+ ...decision,
206
+ messages: [
207
+ ...messages,
208
+ createUserMessage({ content: [{ type: "text", text }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } })
209
+ ]
210
+ };
211
+ });
212
+
213
+ return {
214
+ dispose() {
215
+ unsubscribe?.();
216
+ offStep?.();
217
+ edge.clear();
218
+ }
219
+ };
220
+ }
@@ -334,8 +334,8 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
334
334
  maxTokens: config.sleepMaxTokens ?? 2048,
335
335
  ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
336
336
  messages: [
337
- { role: "system", content: [{ type: "text", text: conflictPrompt }], source: { kind: "plugin", plugin: "dsh-mneme" } },
338
- { role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin", plugin: "dsh-mneme" } }
337
+ { role: "system", content: [{ type: "text", text: conflictPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
338
+ { role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
339
339
  ]
340
340
  }, reportUsage, (reason) => { conflictStreamFailure = describeStreamFailure(reason); }));
341
341
  };
@@ -496,8 +496,8 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
496
496
  maxTokens: config.sleepMaxTokens ?? 2048,
497
497
  ...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
498
498
  messages: [
499
- { role: "system", content: [{ type: "text", text: STR.prompts.pattern[language].replace("N", String(maxPatterns)) }], source: { kind: "plugin", plugin: "dsh-mneme" } },
500
- { role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin", plugin: "dsh-mneme" } }
499
+ { role: "system", content: [{ type: "text", text: STR.prompts.pattern[language].replace("N", String(maxPatterns)) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
500
+ { role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
501
501
  ]
502
502
  }, reportUsage, (reason) => { patternStreamFailure = describeStreamFailure(reason); }));
503
503
  };
package/src/dream.js CHANGED
@@ -2,6 +2,12 @@ import { validateDecisions, applyDecisions } from "./dream/decisions.js";
2
2
  import { clusterMemories, findPotentialConflicts, cosineSimilarity } from "./dream/clustering.js";
3
3
  import { clusterByTag, intersectEvidence } from "./dream/narratives.js";
4
4
  import { scopeKeyOf } from "./scope.js";
5
+ // Issue #239(第 4 项)镜像到巩固:错峰时段解析与「最近的高峰结束时刻」从独立的
6
+ // 零依赖模块 peak-hours.js 取(该模块从 summarize.js 抽出,PR #320 review)——
7
+ // 不另写一份解析器,两份实现漂移会让「同一个时段串在两处行为不同」,那比没有
8
+ // 这个功能更糟。此前直接 import summarize.js,#316 后 summarize 反向依赖 dream
9
+ // (withEffortFallback 复用),会成真循环,故抽模块。
10
+ import { isInPeakWindow, nextOffPeakAt } from "./peak-hours.js";
5
11
  import { createHash, randomUUID } from "node:crypto";
6
12
  import { STR, langOf } from "./lang.js";
7
13
  export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure, resolveDreamEffort, resolveRoute };
@@ -341,6 +347,9 @@ async function runAuditedLlm(ctx, service, config, spec, body) {
341
347
  * accepted → reasoning capped; rejected → provider default (old behavior),
342
348
  * logged so the rejection is observable.
343
349
  */
350
+ // Issue #315:effort 拒收判别式的单一来源。summarize 的流失败以 aborted 结果
351
+ // 返回,需在折叠成 undefined 前用同一甄别(各处手写会漂移)。
352
+ export const EFFORT_REJECT_RE = /reasoning[\s_]*effort|UNSUPPORTED_REASONING_EFFORT/i;
344
353
  async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError) {
345
354
  if (!effort || effort === "none") return attempt();
346
355
  try {
@@ -351,17 +360,20 @@ async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError
351
360
  // on the chunk's failure reason here or the retry below is dead code
352
361
  // for the stream path.
353
362
  const reason = String(getStreamError?.() ?? "");
354
- if (/reasoning[\s_]*effort|UNSUPPORTED_REASONING_EFFORT/i.test(reason)) {
363
+ if (EFFORT_REJECT_RE.test(reason)) {
355
364
  ctx.logger?.warn?.(`dsh-mneme dream: reasoningEffort "${effort}" rejected via stream (${reason}); retrying without it`);
356
365
  return fallback();
357
366
  }
358
367
  }
359
368
  return result;
360
369
  } catch (error) {
370
+ // dispose/取消中止直接放行,绝不能被误判成 effort 拒收而触发 fallback
371
+ //(取消后重打一次不带 effort 的调用是浪费,且可能掩盖真实的取消意图)。
372
+ if (error?.name === "AbortError") throw error;
361
373
  const message = String(error?.message ?? error);
362
374
  // matches both "reasoning effort" (natural language) and the bare
363
375
  // "UNSUPPORTED_REASONING_EFFORT" error code (underscore).
364
- if (!/reasoning[\s_]*effort/i.test(message)) throw error;
376
+ if (!EFFORT_REJECT_RE.test(message)) throw error;
365
377
  ctx.logger?.warn?.(`dsh-mneme dream: reasoningEffort "${effort}" rejected (${message}); retrying without it`);
366
378
  return fallback();
367
379
  }
@@ -410,8 +422,8 @@ async function generateNarratives({ ctx, service, config, route, language, effor
410
422
  maxTokens: config.dreamMaxTokens ?? 2048,
411
423
  ...(withEffort && effort ? { reasoningEffort: effort } : {}),
412
424
  messages: [
413
- { role: "system", content: [{ type: "text", text: STR.prompts.narrative[language] }], source: { kind: "plugin", plugin: "dsh-mneme" } },
414
- { role: "user", content: [{ type: "text", text: listing }], source: { kind: "plugin", plugin: "dsh-mneme" } }
425
+ { role: "system", content: [{ type: "text", text: STR.prompts.narrative[language] }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
426
+ { role: "user", content: [{ type: "text", text: listing }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
415
427
  ]
416
428
  }, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); }));
417
429
  };
@@ -744,8 +756,16 @@ export async function maintainIndexAfterDream(decisions, service, semantic) {
744
756
  if (embedder.modelHash) vectorIndex.markModel?.(embedder.modelHash, embedder.dimension);
745
757
  }
746
758
 
747
- export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, logger, semantic = null, lastRunAtSeed = 0 }) {
759
+ // Issue #292:连续失败退避的间隔封顶(30 分钟)。封顶只拦指数「增长」,不把
760
+ // 用户配得比这更大的 dreamMinIntervalMinutes 基数压小(见 effectiveMinIntervalMs)。
761
+ const FAILURE_BACKOFF_CAP_MS = 30 * 60 * 1000;
762
+
763
+ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, failureBackoff = false, logger, semantic = null, lastRunAtSeed = 0, peakHours = "", peakMaxDeferMinutes = 120, auditPeakSkip = null, now = () => Date.now(), setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout }) {
748
764
  let pendingTimer = null;
765
+ // Issue #239(第 4 项)镜像到巩固:高峰顺延定时器。与 pendingTimer 分开——两者
766
+ // 语义不同(一个是「马上要跑」,一个是「等出高峰再跑」),合成一个变量会让
767
+ // maybeSchedule 的守卫在顺延期间把新的写入触发误当成「已有待跑」而吞掉。
768
+ let deferTimer = null;
749
769
  let running = false;
750
770
  let disposed = false;
751
771
  let baseline = { count: 0, chars: 0 };
@@ -755,6 +775,21 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
755
775
  // lastRunAtSeed:调用方从 dream_runs 审计表读出的上次开跑时刻——
756
776
  // 内存变量进程重启即归零,闸门对新实例放行 → 重启后立刻连发(#89 根因)。
757
777
  let lastRunAt = lastRunAtSeed;
778
+ // Issue #292(#135 派生):同会话内连续失败计数。失败(onRun 抛错或返回
779
+ // ok:false)+1,成功清零;无返回结果的 run(no-op 桩)视为完成且无失败,
780
+ // 不动计数。纯内存变量、宿主重启归零——跨重启的冷却由 lastRunAtSeed(#291)
781
+ // 持久化负责,两者不重叠。
782
+ let consecutiveFailures = 0;
783
+
784
+ // Issue #292:有效最小间隔 = 基数 × 2^连续失败数,封顶 30 分钟。退避关闭或
785
+ // 尚无失败时逐字节返回基数(默认关 = 行为与现状一致)。基数 0 无闸可翻倍
786
+ // (本键不自己产生间隔);封顶取 max(基数, cap),指数再大也不会把用户配的
787
+ // 大基数压小。2^N 溢出成 Infinity 由 Math.min 兜到 cap,无需另设上限位数。
788
+ function effectiveMinIntervalMs() {
789
+ if (!failureBackoff || consecutiveFailures <= 0) return minIntervalMs;
790
+ const cap = Math.max(minIntervalMs, FAILURE_BACKOFF_CAP_MS);
791
+ return Math.min(minIntervalMs * 2 ** consecutiveFailures, cap);
792
+ }
758
793
 
759
794
  function shouldTrigger(service) {
760
795
  const memories = service.all().filter((m) => !m.archived && m.type !== "summary" && m.type !== "document");
@@ -766,51 +801,115 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
766
801
  }
767
802
 
768
803
  function maybeSchedule(service) {
769
- if (disposed || running || pendingTimer) return false;
770
- // Issue #89(请求 2):最小触发间隔闸门。
771
- if (minIntervalMs > 0 && Date.now() - lastRunAt < minIntervalMs) return false;
804
+ if (disposed || running || pendingTimer || deferTimer) return false;
805
+ // Issue #89(请求 2):最小触发间隔闸门。#292 退避开启时改用指数放大的
806
+ // 有效间隔:连续失败越多,下次放行越晚(恒定失败的模型不再按固定节奏连发,
807
+ // 只会越打越稀)。失败 run 本就占用间隔(lastRunAt 在开跑时刷新,见 #89),
808
+ // 这里放大的是同一道闸,不新增任何状态面。间隔内的触发静默跳过:没有调用
809
+ // 发生,也就没有可审计的对象(与 #89 口径一致,不写审计行)。
810
+ if (minIntervalMs > 0 && now() - lastRunAt < effectiveMinIntervalMs()) return false;
772
811
  const { trigger, count, chars } = shouldTrigger(service);
773
812
  if (!trigger) return false;
774
- pendingTimer = setTimeout(() => {
813
+ // Issue #239(第 4 项,错峰队列)镜像到巩固:命中高峰就不调模型。与蒸馏的差别
814
+ // 在于巩固是**全局单实例**(蒸馏按会话各挂一个定时器),所以这里只需要一个
815
+ // deferTimer,且不需要 deferredRuns 那套按会话去重。
816
+ // baseline 刻意不刷新:阈值继续累积,留到非高峰一次性巩固(一次大 run 比多次
817
+ // 小 run 省)。审计只登记一行 skip——「为什么不再做梦了」必须对用户可观测。
818
+ if (isInPeakWindow(new Date(now()), peakHours)) {
819
+ try {
820
+ auditPeakSkip?.({ count, chars });
821
+ } catch (error) {
822
+ // 记账是 best-effort:写审计行失败只 warn,绝不反噬调度本身。
823
+ logger?.warn?.(`dsh-mneme dream: peak-hours audit failed: ${String(error)}`);
824
+ }
825
+ scheduleDeferredRun(service);
826
+ return false;
827
+ }
828
+ pendingTimer = setTimeoutFn(() => {
775
829
  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
- });
830
+ startRun(service);
807
831
  }, delayMs);
808
832
  return true;
809
833
  }
810
834
 
835
+ /**
836
+ * Issue #239:高峰内择时补跑——挂到「距当前最近的一个高峰结束时刻」,被
837
+ * peakMaxDeferMinutes 截断时到点照跑(bypassPeak),长高峰不会把巩固饿死。
838
+ * 定时器 unref:不阻止宿主退出。重复触发不叠加(deferTimer 已在 maybeSchedule
839
+ * 的守卫里,这里再判一次以防从其它路径进来)。
840
+ */
841
+ function scheduleDeferredRun(service) {
842
+ if (disposed || deferTimer) return;
843
+ const at = nextOffPeakAt(new Date(now()), peakHours);
844
+ if (!at) return;
845
+ const maxDeferMs = (peakMaxDeferMinutes ?? 0) * 60000;
846
+ let delay = Math.max(0, at.getTime() - now());
847
+ const capped = maxDeferMs > 0 && delay > maxDeferMs;
848
+ if (capped) delay = maxDeferMs;
849
+ deferTimer = setTimeoutFn(() => {
850
+ deferTimer = null;
851
+ if (disposed) return;
852
+ // 截断放行时仍在高峰:不再重新顺延(否则长高峰里会无限顺延,等于把巩固
853
+ // 关掉)。直接开跑,与蒸馏的 bypassPeak 同口径。
854
+ if (!capped && isInPeakWindow(new Date(now()), peakHours)) {
855
+ // 理论上到点已出高峰;时钟跳变/时段串被改小可能落回高峰内,此时再顺延一次。
856
+ scheduleDeferredRun(service);
857
+ return;
858
+ }
859
+ logger?.info?.(`dsh-mneme dream: peak-hours deferred run firing (capped=${capped}, delayMs=${delay})`);
860
+ startRun(service);
861
+ }, delay);
862
+ deferTimer.unref?.();
863
+ }
864
+
865
+ /**
866
+ * 真正开跑。抽出来是因为两条路径都要用:写入触发的正常路径,与高峰顺延后的
867
+ * 补跑路径。onRun 的调用刻意放在 Promise 里——同步抛出的异常若逃出 timer 回调
868
+ * 会直接崩掉进程并跳过收尾。inFlight 让 dispose() 能等完这一轮再关库。
869
+ */
870
+ function startRun(service) {
871
+ running = true;
872
+ lastRunAt = now();
873
+ inFlight = Promise.resolve()
874
+ .then(() => (onRun ? onRun() : Promise.resolve({ ok: true, skipped: true })))
875
+ .then((result) => {
876
+ // Refresh the baseline only for a successful run (design §5.3: an
877
+ // LLM failure must not move the baseline, so the next write can
878
+ // immediately re-trigger a retry). A `{ok:false}` result or a throw
879
+ // keeps the old baseline. A run that reports nothing is treated as
880
+ // completed without failure (no-op hooks / minimal test doubles).
881
+ if (result && result.ok) {
882
+ consecutiveFailures = 0; // Issue #292:成功清零,下次触发回到基数间隔
883
+ try {
884
+ baseline = shouldTrigger(service);
885
+ } catch (error) {
886
+ // Store closed mid-flight: keep the last known baseline.
887
+ logger?.warn?.(`dsh-mneme dream: baseline refresh failed: ${String(error)}`);
888
+ }
889
+ } else if (result) {
890
+ // Issue #292:ok:false(LLM 失败 / 空体 / 整单拒绝)计入连败;degraded
891
+ // 的 run 走 ok:true(LLM 本身成功了,只是决策被部分应用)→ 算成功、清零。
892
+ // 退避关闭时该计数没有消费者,行为与此前逐字节一致。
893
+ consecutiveFailures += 1;
894
+ }
895
+ // result 为空(no-op 桩 / 最小测试替身)=完成且无失败:基线与连败计数都不动。
896
+ })
897
+ .catch((error) => {
898
+ logger?.warn?.(`dsh-mneme dream: run failed: ${error?.message ?? error}`);
899
+ // Failed runs do not refresh the baseline.
900
+ consecutiveFailures += 1; // Issue #292:抛错同样计入连败
901
+ })
902
+ .finally(() => {
903
+ running = false;
904
+ inFlight = null;
905
+ });
906
+ }
907
+
811
908
  async function dispose() {
812
909
  disposed = true;
813
- if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; }
910
+ if (pendingTimer) { clearTimeoutFn(pendingTimer); pendingTimer = null; }
911
+ // Issue #239:高峰顺延定时器同样要清,否则进程关闭后仍会触发一次巩固。
912
+ if (deferTimer) { clearTimeoutFn(deferTimer); deferTimer = null; }
814
913
  // An in-flight run is left to complete naturally (its LLM calls are
815
914
  // already paid for and aborting would discard the work). Await it so the
816
915
  // caller can close the store only after every write has landed.
@@ -1001,8 +1100,8 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1001
1100
  maxTokens: config.dreamMaxTokens ?? 4096,
1002
1101
  ...(withEffort && effort ? { reasoningEffort: effort } : {}),
1003
1102
  messages: [
1004
- { role: "system", content: [{ type: "text", text: consolidationPrompt }], source: { kind: "plugin", plugin: "dsh-mneme" } },
1005
- { role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin", plugin: "dsh-mneme" } }
1103
+ { role: "system", content: [{ type: "text", text: consolidationPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
1104
+ { role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
1006
1105
  ]
1007
1106
  }, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); }));
1008
1107
  };
@@ -1236,8 +1335,8 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1236
1335
  maxTokens: config.dreamMaxTokens ?? 2048,
1237
1336
  ...(withEffort && effort ? { reasoningEffort: effort } : {}),
1238
1337
  messages: [
1239
- { role: "system", content: [{ type: "text", text: STR.prompts.dreamSummary[language] }], source: { kind: "plugin", plugin: "dsh-mneme" } },
1240
- { role: "user", content: [{ type: "text", text: summaryInputs.map((m) => `- ${m.title}: ${m.content}`).join("\n") }], source: { kind: "plugin", plugin: "dsh-mneme" } }
1338
+ { role: "system", content: [{ type: "text", text: STR.prompts.dreamSummary[language] }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
1339
+ { role: "user", content: [{ type: "text", text: summaryInputs.map((m) => `- ${m.title}: ${m.content}`).join("\n") }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
1241
1340
  ]
1242
1341
  }, reportUsage, (reason) => { summaryStreamFailure = describeStreamFailure(reason); }));
1243
1342
  };
@@ -150,8 +150,8 @@ export async function extractEntities(memory, { store, config, callLLM, logger }
150
150
  const userText = buildUserMessage(memory.content);
151
151
 
152
152
  const messages = [
153
- { role: "system", content: [{ type: "text", text: systemPrompt }], source: { kind: "plugin", plugin: "dsh-mneme" } },
154
- { role: "user", content: [{ type: "text", text: userText }], source: { kind: "plugin", plugin: "dsh-mneme" } }
153
+ { role: "system", content: [{ type: "text", text: systemPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
154
+ { role: "user", content: [{ type: "text", text: userText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
155
155
  ];
156
156
 
157
157
  // Issue #109: optional provider/model override + reasoning effort are