@modusensus/dsh-mneme 0.8.7 → 0.8.8

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/lib/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
  }
@@ -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.
package/lib/guide.js CHANGED
@@ -42,3 +42,21 @@ export const TOOL_GUIDE = {
42
42
  " Save only durable, cross-session value (a preference, a decision with its rationale, an engineering constraint, " +
43
43
  "a pitfall with its root cause). Trivial single-turn work does not belong here, and when unsure, do not save."
44
44
  };
45
+
46
+ /**
47
+ * #249 N3 的降级路径:宿主若不提供可挂钩的压缩前时机,双落点里的「注入」那一半就没
48
+ * 有触发者。此时不把功能算作失败,而是把规则交给 agent 自判压力(§4.4 点名的降级
49
+ * 形态)。落点是工具描述——常驻文本、不进每轮上下文,零注入成本。
50
+ *
51
+ * 英文单一正本,理由同 TOOL_GUIDE(见文件头)。字段名用存储与注入那一侧的口径
52
+ * (`current_work` / `next_step` / open question,#249 §6.2);规格 §4.4 里的
53
+ * Current Work / Next Step / Critical Context 是**宿主压缩模板**的字段名,不是这里的。
54
+ */
55
+ export const CONTINUITY_TOOL_RULE = {
56
+ memory_save:
57
+ " If this session's context is about to be compacted, first save a continuity note with this same tool " +
58
+ "(type: project, title: one stable title for this line of work, e.g. \"continuity: <topic>\"): current_work (what you " +
59
+ "are doing now), next_step, and any open question. Keep it to those fields, and reuse that same title for later " +
60
+ "updates: the store merges rows of the same type + title (the merge appends to the note instead of replacing it), " +
61
+ "so a fresh title each time just leaves a trail of near-duplicate project rows."
62
+ };
package/lib/index.js CHANGED
@@ -7,6 +7,7 @@ import { createService } from "./service.js";
7
7
  import { createWriteAdmission } from "./write-admission.js";
8
8
  import { createTools } from "./tools.js";
9
9
  import { createInjector } from "./inject.js";
10
+ import { createContinuityRescue } from "./continuity.js";
10
11
  import { createSummarizer } from "./summarize.js";
11
12
  import { createDreamScheduler } from "./dream.js";
12
13
  import { createSleepScheduler, runSleep } from "./dream/sleep.js";
@@ -20,7 +21,7 @@ import { createEmbedder } from "./embedding.js";
20
21
  import { createEmbedderByProvider } from "./local-embedder.js";
21
22
  import { LocalReranker } from "./reranker.js";
22
23
  import { createVectorIndex } from "./vector-index.js";
23
- import { Config, applyLightModePreset } from "./config.js";
24
+ import { Config, applyLightModePreset, injectChildEnabled } from "./config.js";
24
25
  import { langOf } from "./lang.js";
25
26
  import { extractEntities } from "./entities/extractor.js";
26
27
  import { mkdirSync } from "node:fs";
@@ -490,9 +491,37 @@ export const apply = (ctx, config) => {
490
491
  thresholdChars: cfg.dreamThresholdChars,
491
492
  delayMs: cfg.dreamDelayMs,
492
493
  minIntervalMs: (cfg.dreamMinIntervalMinutes ?? 0) * 60000,
494
+ // Issue #292:连续失败指数退避(opt-in,默认关 = 行为不变)。
495
+ failureBackoff: cfg.autoDreamFailureBackoff === true,
493
496
  logger: ctx.logger,
494
497
  semantic: { embedder, vectorIndex },
495
498
  lastRunAtSeed: store.lastDreamRunAt("auto"),
499
+ // Issue #239(第 4 项)镜像到巩固:高峰期不做梦,顺延到最近的高峰结束时刻。
500
+ peakHours: cfg.dreamPeakHours ?? "",
501
+ peakMaxDeferMinutes: cfg.dreamPeakMaxDeferMinutes ?? 120,
502
+ // 跳过时的审计行在这里落地(调度器只拿到 service,拿不到 config 的
503
+ // llmAudit 开关与巩固模型路由)。口径与 runAuditedLlm 一致:审计关掉就
504
+ // 不写;写失败只 warn,绝不反噬调度(CONTRIBUTING 的 fail-safe 硬约定)。
505
+ auditPeakSkip: ({ count, chars }) => {
506
+ if (cfg?.llmAudit?.enabled === false || typeof service?.saveLlmAudit !== "function") return;
507
+ const modelId = cfg.dreamProvider && cfg.dreamModel ? `${cfg.dreamProvider}:${cfg.dreamModel}` : "";
508
+ service.saveLlmAudit({
509
+ timestamp: new Date().toISOString(),
510
+ trigger_source: "autoDream",
511
+ operation_type: "dream_consolidate",
512
+ model_id: modelId,
513
+ input_tokens: 0,
514
+ output_tokens: 0,
515
+ total_tokens: 0,
516
+ cost_usd: 0,
517
+ duration_ms: 0,
518
+ status: "skipped",
519
+ error_message: "peak-hours",
520
+ related_memory_ids: [],
521
+ // 观测用:跳过时窗口里积了多少(阈值继续累积,不是丢弃)。
522
+ metadata: JSON.stringify({ count, chars })
523
+ });
524
+ },
496
525
  onRun: () => (dream ? dream.runDream(ctx, service, cfg) : Promise.resolve({ ok: true, skipped: true }))
497
526
  });
498
527
  service.setDreamHook(() => dream.maybeSchedule(service));
@@ -559,6 +588,13 @@ export const apply = (ctx, config) => {
559
588
  if (cfg.autoInject) disposers.push(createInjector(promptCtx, service, settings, cfg));
560
589
  });
561
590
 
591
+ // #249 N3:压缩边缘双落点。触发靠宿主自己落的压缩事件(订阅 + pre-step 追加),
592
+ // 不需要 systemPrompt / tools 的任何能力,所以不塞进上面的 inject 回调;父/子
593
+ // 闸门在挂载点判一次,与 inject.js 共用同一个 injectChildEnabled 判据。
594
+ if (cfg.autoInject && injectChildEnabled(cfg, "continuityRescueEnabled")) {
595
+ disposers.push(createContinuityRescue(ctx, store));
596
+ }
597
+
562
598
  ctx.inject(["tools"], (toolsCtx) => {
563
599
  disposers.push(createTools(toolsCtx, service, cfg, embedder));
564
600
  });
@@ -0,0 +1,117 @@
1
+ // 高峰时段(peak hours)解析与判定——纯函数模块,零依赖。
2
+ //
3
+ // 从 summarize.js 抽出(PR #320 review):#316 之后 summarize.js 反向依赖
4
+ // dream.js(withEffortFallback 复用),dream 镜像错峰时再 import summarize
5
+ // 会成真循环。时段调度本来就不是蒸馏的私有语义,独立成模块后两侧都从这里
6
+ // import,循环消失。re-export 兼容留在 summarize.js,调用方零改动。
7
+ //
8
+ // Issue #239(第 4 项,错峰队列):高峰时段解析与判定。纯函数、可单测——排程
9
+ // 判断不绑死真实时钟,测试才能确定性地覆盖跨零点、多段、星期过滤与非法写法。
10
+ // spec 语法:`[<星期> ]<时段>[,<时段>...]`,星期前缀可省(省 = 每天):
11
+ // "09:00-18:00" 每天 09:00-18:00
12
+ // "mon-fri 08:00-12:00,14:00-18:00" 工作日两段(ISO 1=周一…7=周日,也认 mon..sun)
13
+ // "sat,sun 23:00-06:00" 周末跨零点段
14
+ // 时间取宿主本地时区。任一写法非法 → 整串视为未配置(返回 null):排程是省钱手段,
15
+ // 绝不该因为写错格式把蒸馏/巩固停掉。
16
+ const DAY_NAMES = { mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sun: 7 };
17
+
18
+ function parseDayToken(token) {
19
+ const days = new Set();
20
+ const normalize = (value) => (/^\d$/.test(value) ? Number(value) : DAY_NAMES[value] ?? null);
21
+ for (const piece of token.split(",")) {
22
+ const matched = /^([a-z]{3}|\d)(?:-([a-z]{3}|\d))?$/.exec(piece.trim().toLowerCase());
23
+ if (!matched) return null;
24
+ const from = normalize(matched[1]);
25
+ const to = matched[2] === undefined ? from : normalize(matched[2]);
26
+ if (from === null || to === null || from < 1 || from > 7 || to < 1 || to > 7) return null;
27
+ // 支持跨周环绕(fri-mon):从 from 起逐天推进到 to,最多绕一圈。
28
+ for (let day = from; ; day = (day % 7) + 1) {
29
+ days.add(day);
30
+ if (day === to) break;
31
+ }
32
+ }
33
+ return days.size > 0 ? [...days].sort((a, b) => a - b) : null;
34
+ }
35
+
36
+ /** JS 的 getDay() 是 0=周日…6=周六;这里统一成 ISO(1=周一…7=周日)。 */
37
+ function isoDay(date) {
38
+ const day = date.getDay();
39
+ return day === 0 ? 7 : day;
40
+ }
41
+
42
+ export function parsePeakSpec(spec) {
43
+ if (typeof spec !== "string" || spec.trim() === "") return null;
44
+ let rest = spec.trim();
45
+ let days = null;
46
+ // 星期前缀 = 第一个空白之前的部分,但**头段含冒号就不是前缀**(那是时段本身,
47
+ // 例如 "09:00-12:00, 14:00-18:00" 里的逗号空格)。前缀解析失败一律按未配置处理,
48
+ // 不做猜测——宁可不省,也不能误停。
49
+ const sep = rest.search(/\s/);
50
+ if (sep > 0) {
51
+ const head = rest.slice(0, sep);
52
+ if (!head.includes(":")) {
53
+ days = parseDayToken(head);
54
+ if (days === null) return null;
55
+ rest = rest.slice(sep).trim();
56
+ }
57
+ }
58
+ const windows = [];
59
+ for (const part of rest.split(",")) {
60
+ const matched = /^\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s*$/.exec(part);
61
+ if (!matched) return null;
62
+ const [sh, sm, eh, em] = [Number(matched[1]), Number(matched[2]), Number(matched[3]), Number(matched[4])];
63
+ if (sh > 23 || eh > 23 || sm > 59 || em > 59) return null;
64
+ const start = sh * 60 + sm;
65
+ const end = eh * 60 + em;
66
+ if (start === end) return null;
67
+ windows.push({ start, end });
68
+ }
69
+ return windows.length > 0 ? { days, windows } : null;
70
+ }
71
+
72
+ /**
73
+ * 该时刻是否落在高峰内。跨零点段(start > end)按「窗口所属的那一天」认星期:
74
+ * `mon-fri 23:00-06:00` 的周六 02:00 属于周五开的那个窗口,仍算高峰。
75
+ */
76
+ export function isInPeakWindow(date, spec) {
77
+ const parsed = parsePeakSpec(spec);
78
+ if (!parsed) return false;
79
+ const minutes = date.getHours() * 60 + date.getMinutes();
80
+ const today = isoDay(date);
81
+ const yesterday = today === 1 ? 7 : today - 1;
82
+ const allowed = (day) => !parsed.days || parsed.days.includes(day);
83
+ return parsed.windows.some(({ start, end }) => {
84
+ if (start < end) return minutes >= start && minutes < end && allowed(today);
85
+ return (minutes >= start && allowed(today)) || (minutes < end && allowed(yesterday));
86
+ });
87
+ }
88
+
89
+ /**
90
+ * 高峰内则返回「距当前最近的一个高峰结束时刻」(择时补跑用),否则 null。
91
+ * 落在多个时段重叠处时取最早结束的那个——早跑不亏,晚跑才亏。
92
+ */
93
+ export function nextOffPeakAt(date, spec) {
94
+ const parsed = parsePeakSpec(spec);
95
+ if (!parsed) return null;
96
+ const minutes = date.getHours() * 60 + date.getMinutes();
97
+ const today = isoDay(date);
98
+ const yesterday = today === 1 ? 7 : today - 1;
99
+ const allowed = (day) => !parsed.days || parsed.days.includes(day);
100
+ let bestDelta = null;
101
+ const consider = (delta) => {
102
+ if (bestDelta === null || delta < bestDelta) bestDelta = delta;
103
+ };
104
+ for (const { start, end } of parsed.windows) {
105
+ if (start < end) {
106
+ if (minutes >= start && minutes < end && allowed(today)) consider(end - minutes);
107
+ continue;
108
+ }
109
+ if (minutes >= start && allowed(today)) consider((1440 - minutes) + end);
110
+ else if (minutes < end && allowed(yesterday)) consider(end - minutes);
111
+ }
112
+ if (bestDelta === null) return null;
113
+ const at = new Date(date.getTime());
114
+ at.setSeconds(0, 0);
115
+ at.setMinutes(at.getMinutes() + bestDelta);
116
+ return at;
117
+ }
package/lib/settings.js CHANGED
@@ -48,6 +48,9 @@ const FEATURE_FLAG_BOOLEANS = [
48
48
  // Issue #249:能力说明(工具描述判断指引 + order 150 总则段)。第二批起它是
49
49
  // 注入父开关 autoInject 的子项、默认开(父关时不生效,闸门见 config.js)。
50
50
  "injectGuidanceEnabled",
51
+ // Issue #249 N3:压缩边缘双落点——上下文即将精简前落一条连续性提案 + 往
52
+ // 序列末尾追加同一份快照。默认关(新注入表面,见 config.js),父开关 autoInject。
53
+ "continuityRescueEnabled",
51
54
  "entityExtractionEnabled",
52
55
  // Issue #219:图召回轴——查询命中实体名时把挂联记忆并入检索融合池
53
56
  // (默认关;依赖实体抽取产出,lightMode 强制关闭)。
@@ -60,6 +63,9 @@ const FEATURE_FLAG_BOOLEANS = [
60
63
  "documentMemoryEnabled",
61
64
  "codingRetrospect",
62
65
  "autoDream",
66
+ // Issue #292:autoDream 连续失败退避(默认关)。基数是 dreamMinIntervalMinutes,
67
+ // 有效间隔 = 基数 × 2^连续失败数(成功清零),封顶 30 分钟;面板可启停。
68
+ "autoDreamFailureBackoff",
63
69
  "sleepModeEnabled",
64
70
  "heatEnabled",
65
71
  "hybridInject",
@@ -114,6 +120,8 @@ const FEATURE_FLAG_INT_RANGES = {
114
120
  summarizeMaxRunsPerSession: [0, 1000],
115
121
  // Issue #239 第 4 项:高峰顺延上限(分钟,0 = 不设上限)。
116
122
  summarizePeakMaxDeferMinutes: [0, 1440],
123
+ // Issue #239 第 4 项镜像到巩固:同一口径(分钟,0 = 不设上限)。
124
+ dreamPeakMaxDeferMinutes: [0, 1440],
117
125
  // Issue #125:hybrid 候选量上限(0 = 复用 dreamMaxSnapshotSize)。
118
126
  dreamCandidateMax: [0, 5000],
119
127
  // Issue #164①:注入单条正文截断上限(默认 300 = 既有行为)。
@@ -156,7 +164,9 @@ const FEATURE_FLAG_STRINGS = [
156
164
  "localEmbedModel",
157
165
  "ollamaModel",
158
166
  // Issue #239 第 4 项:高峰时段串("09:00-18:00",空串 = 关闭)。
159
- "summarizePeakHours"
167
+ "summarizePeakHours",
168
+ // Issue #239 第 4 项镜像到巩固:同一份时段语法,空串 = 关闭(行为与现状一致)。
169
+ "dreamPeakHours"
160
170
  ];
161
171
  // URL 字符串开关:trim 后必须为空或合法 http/https URL(new URL() 校验协议,
162
172
  // 拒绝其余协议——这是 SSRF 防线的一部分)。
@@ -169,6 +179,9 @@ const FEATURE_FLAG_ENUMS = {
169
179
  recallFusion: ["blend", "rrf", "minmax"],
170
180
  // 实体抽取思考强度(issue #109):与 dreamReasoningEffort 枚举对齐。
171
181
  entityExtractionReasoning: ["low", "medium", "high", "none"],
182
+ // 蒸馏思考强度(issue #315):与 entityExtractionReasoning 枚举对齐,
183
+ // 多一个 off(显式关思考,思考型模型蒸馏防推理烧预算)。
184
+ summarizeReasoningEffort: ["off", "low", "medium", "high", "none"],
172
185
  // Issue #127:落库前去重档位(off 默认,等同现状)。
173
186
  summarizeDedupeMode: ["off", "title", "vector"],
174
187
  // Issue #126:sleep 冲突阶段的动作集(conflict 默认 = 现状;full = 六分支)。
package/lib/store.js CHANGED
@@ -276,6 +276,28 @@ CREATE TABLE IF NOT EXISTS mirror_state (
276
276
  applied_generation INTEGER NOT NULL DEFAULT 0 CHECK (applied_generation >= 0 AND applied_generation <= 9007199254740991 AND applied_generation = CAST(applied_generation AS INTEGER)), -- 已成功应用的轮次
277
277
  type_status TEXT -- JSON: 逐 type 状态 {type: {dirty, applied_gen, last_error}}
278
278
  );
279
+
280
+ -- #249 N3(压缩边缘双落点):连续性提案。压缩边缘只落**提案**行,不进 memories
281
+ -- ——边缘产出若直接进记忆库,一个长会话就会攒出第 N 条同主题条目,正是 #275 记的
282
+ -- 失败形态;转正通道与 #254 的二次确认共用一套,阶段二才打开,本批只写 pending。
283
+ -- 唯一键 (session_id, kind) 就是形态约定里的「同一会话同一类只留一条」:再次触发
284
+ -- 是刷新同一行,不是新增一行。status 为转正通道预留(pending → promoted/discarded):
285
+ -- 「实际触发率」= 提案行数、「采纳率」= promoted/总数,都能就地统计(#249 §8)。
286
+ -- 时间戳是 ISO(与 memories 同款),created_at 在刷新时不动,updated_at 记最近边缘。
287
+ CREATE TABLE IF NOT EXISTS continuity_proposals (
288
+ id TEXT PRIMARY KEY,
289
+ session_id TEXT NOT NULL,
290
+ kind TEXT NOT NULL,
291
+ current_work TEXT,
292
+ next_step TEXT,
293
+ open_questions TEXT,
294
+ status TEXT NOT NULL DEFAULT 'pending',
295
+ edge_seq INTEGER, -- 触发本次刷新的压缩事件 seq(证据/复现用)
296
+ created_at TEXT NOT NULL,
297
+ updated_at TEXT NOT NULL
298
+ );
299
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_continuity_session_kind ON continuity_proposals(session_id, kind);
300
+ CREATE INDEX IF NOT EXISTS idx_continuity_status ON continuity_proposals(status, updated_at);
279
301
  `;
280
302
 
281
303
  // Exported for API-layer type validation (standalone API POST /memories and
@@ -286,6 +308,12 @@ export const TYPES = new Set(["preference", "project", "decision", "history", "s
286
308
  // (registerDocument)——saveWithDedupe/updateMemory 另有守卫拒绝旁路铸造。
287
309
  "document"]);
288
310
 
311
+ // #249 N3:连续性提案的 pending 队列上限。「队列满则弃新」是形态约定的一半——满时丢
312
+ // 掉本次触发,**不是**淘汰旧行:旧行是别的会话还没转正的活状态,用"更近的边缘"把它挤
313
+ // 掉,等于让长会话的噪音吃掉短会话的真实工作状态。这个数是存储侧策略,与抽取/注入的
314
+ // 截断上限(continuity.js)是两件事。
315
+ const MAX_CONTINUITY_PENDING = 200;
316
+
289
317
  // Epistemic status: what kind of evidence a memory rests on. Defaults to
290
318
  // 'subjective' so legacy rows (and rows without any signal) stay compatible.
291
319
  const EPISTEMIC_STATUSES = new Set(["observation", "subjective", "inferred"]);
@@ -2645,6 +2673,60 @@ export function createStore(path) {
2645
2673
  * Otherwise wrap in BEGIN/COMMIT so a memory write and its desired-generation
2646
2674
  * bump commit together: a crash between them can never leave a mutated store
2647
2675
  * with generation == applied (audit peer blocker 1, "crash window"). */
2676
+ // --- #249 N3:压缩边缘的连续性提案(唯一写入口 = 压缩边缘监听器) -------------
2677
+ /** Upsert one continuity proposal. (session_id, kind) is the形态约定「同一会话同一类
2678
+ * 只留一条」:命中即刷新(created_at 不动、updated_at 记最近边缘),不新增行。
2679
+ * Queue full → drop the new one and say so (dropped: true) instead of throwing: an edge
2680
+ * that cannot be recorded must not break the host's step. Returns {id, created, dropped}. */
2681
+ function saveContinuityProposal({
2682
+ sessionId, kind, currentWork = null, nextStep = null, openQuestions = null, edgeSeq = null,
2683
+ maxPending = MAX_CONTINUITY_PENDING
2684
+ } = {}) {
2685
+ if (!sessionId || !kind) throw new TypeError("continuity proposal needs sessionId and kind");
2686
+ const now = new Date().toISOString();
2687
+ const existing = db.prepare("SELECT id FROM continuity_proposals WHERE session_id = ? AND kind = ?").get(sessionId, kind);
2688
+ if (!existing) {
2689
+ const pending = db.prepare("SELECT COUNT(*) AS c FROM continuity_proposals WHERE status = 'pending'").get().c;
2690
+ if (pending >= maxPending) return { id: null, created: false, dropped: true };
2691
+ }
2692
+ // 写入本身是一条 UPSERT:唯一索引 (session_id, kind) 是并发的裁决者。两个宿主共用
2693
+ // memoryDir 时(AGENTS.md 的多进程 WAL 场景)先查后插会有一个撞 SQLITE_CONSTRAINT,
2694
+ // 而这里只该有一条语句决定成不成功。上面的 SELECT 只用于「队列满」与 created 标记。
2695
+ // created_at 与 status 刻意不在 SET 里:命中只刷新内容与最近边缘时刻,不把已转正的行打回 pending。
2696
+ const id = randomUUID();
2697
+ db.prepare(`
2698
+ INSERT INTO continuity_proposals (id, session_id, kind, current_work, next_step, open_questions, status, edge_seq, created_at, updated_at)
2699
+ VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)
2700
+ ON CONFLICT(session_id, kind) DO UPDATE SET
2701
+ current_work = excluded.current_work,
2702
+ next_step = excluded.next_step,
2703
+ open_questions = excluded.open_questions,
2704
+ edge_seq = excluded.edge_seq,
2705
+ updated_at = excluded.updated_at
2706
+ `).run(id, sessionId, kind, currentWork, nextStep, openQuestions, edgeSeq, now, now);
2707
+ const row = db.prepare("SELECT id FROM continuity_proposals WHERE session_id = ? AND kind = ?").get(sessionId, kind);
2708
+ return { id: row?.id ?? id, created: !existing, dropped: false };
2709
+ }
2710
+
2711
+ /** The single proposal for (session, kind), or null. */
2712
+ function getContinuityProposal(sessionId, kind) {
2713
+ return db.prepare("SELECT * FROM continuity_proposals WHERE session_id = ? AND kind = ?").get(sessionId, kind) ?? null;
2714
+ }
2715
+
2716
+ /** Newest-first proposals, optionally filtered by status (转正通道的读侧预置)。 */
2717
+ function listContinuityProposals({ status, limit = 50 } = {}) {
2718
+ const bounded = Number.isSafeInteger(limit) && limit > 0 ? limit : 50;
2719
+ return status
2720
+ ? db.prepare("SELECT * FROM continuity_proposals WHERE status = ? ORDER BY updated_at DESC, id LIMIT ?").all(status, bounded)
2721
+ : db.prepare("SELECT * FROM continuity_proposals ORDER BY updated_at DESC, id LIMIT ?").all(bounded);
2722
+ }
2723
+
2724
+ function countContinuityProposals({ status } = {}) {
2725
+ return status
2726
+ ? db.prepare("SELECT COUNT(*) AS c FROM continuity_proposals WHERE status = ?").get(status).c
2727
+ : db.prepare("SELECT COUNT(*) AS c FROM continuity_proposals").get().c;
2728
+ }
2729
+
2648
2730
  function runAtomically(fn) {
2649
2731
  if (db.isTransaction) return fn();
2650
2732
  db.exec("BEGIN");
@@ -2769,6 +2851,11 @@ export function createStore(path) {
2769
2851
  setTypeStatus,
2770
2852
  getTypeStatus,
2771
2853
  incrementGeneration,
2854
+ // #249 N3:压缩边缘的连续性提案(落提案、转正才进 memories)。
2855
+ saveContinuityProposal,
2856
+ getContinuityProposal,
2857
+ listContinuityProposals,
2858
+ countContinuityProposals,
2772
2859
  close() {
2773
2860
  db.close();
2774
2861
  }