@modusensus/dsh-mneme 0.8.6 → 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.
Files changed (68) hide show
  1. package/README.md +19 -148
  2. package/bin/cli.mjs +689 -603
  3. package/lib/api-standalone.js +57 -1
  4. package/lib/api.js +34 -11
  5. package/lib/client.js +349 -61
  6. package/lib/config.js +118 -7
  7. package/lib/content-hash.js +54 -0
  8. package/lib/continuity.js +220 -0
  9. package/lib/document-index.js +94 -0
  10. package/lib/document.js +93 -11
  11. package/lib/dream.js +138 -39
  12. package/lib/guide.js +18 -0
  13. package/lib/index.js +87 -12
  14. package/lib/inject.js +6 -2
  15. package/lib/lang.js +15 -4
  16. package/lib/maintenance.js +142 -0
  17. package/lib/mirror.js +147 -15
  18. package/lib/peak-hours.js +117 -0
  19. package/lib/recall-stats.js +52 -2
  20. package/lib/scope.js +34 -6
  21. package/lib/service.js +86 -7
  22. package/lib/settings.js +16 -2
  23. package/lib/store.js +318 -21
  24. package/lib/summarize.js +52 -113
  25. package/lib/tools.js +37 -11
  26. package/lib/write-admission.js +259 -0
  27. package/package.json +4 -4
  28. package/src/api-standalone.js +57 -1
  29. package/src/api.js +34 -11
  30. package/src/config.js +118 -7
  31. package/src/content-hash.js +54 -0
  32. package/src/continuity.js +220 -0
  33. package/src/document-index.js +94 -0
  34. package/src/document.js +93 -11
  35. package/src/dream.js +138 -39
  36. package/src/guide.js +18 -0
  37. package/src/index.js +87 -12
  38. package/src/inject.js +6 -2
  39. package/src/lang.js +15 -4
  40. package/src/maintenance.js +142 -0
  41. package/src/mirror.js +147 -15
  42. package/src/peak-hours.js +117 -0
  43. package/src/recall-stats.js +52 -2
  44. package/src/scope.js +34 -6
  45. package/src/service.js +86 -7
  46. package/src/settings.js +16 -2
  47. package/src/store.js +318 -21
  48. package/src/summarize.js +52 -113
  49. package/src/tools.js +37 -11
  50. package/src/write-admission.js +259 -0
  51. package/test/api.test.js +21 -3
  52. package/test/client.test.js +103 -6
  53. package/test/content-hash.test.js +204 -0
  54. package/test/continuity.test.js +333 -0
  55. package/test/document-dir.test.js +233 -0
  56. package/test/document.test.js +151 -3
  57. package/test/dream-failure-backoff.test.js +173 -0
  58. package/test/dream-peak-hours.test.js +194 -0
  59. package/test/inject-parent-gate.test.js +106 -0
  60. package/test/inject-pools.test.js +8 -3
  61. package/test/llm-audit.test.js +42 -0
  62. package/test/maintenance.test.js +391 -0
  63. package/test/mirror-types.test.js +208 -0
  64. package/test/reasoning-effort.test.js +6 -0
  65. package/test/recall-evals.test.js +18 -0
  66. package/test/recall-stats.test.js +66 -0
  67. package/test/summarize-reasoning-effort.test.js +189 -0
  68. package/test/write-admission.test.js +345 -0
package/lib/document.js CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { statSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
- import { isAbsolute, resolve } from "node:path";
16
+ import { isAbsolute, resolve, sep } from "node:path";
17
17
  import { scopeKeyOf } from "./scope.js";
18
18
  import { cosineSimilarity } from "./dream/clustering.js";
19
19
 
@@ -23,26 +23,71 @@ import { cosineSimilarity } from "./dream/clustering.js";
23
23
  // 新版判成「无近重复」,留下两行活跃摘要漂移——supersede 探测必须全时段。
24
24
  const DOCUMENT_MIN_SIM = 0.92;
25
25
  const CANDIDATE_LIMIT = 200;
26
+ const DOCUMENT_DIR_NAME = "documents";
27
+
28
+ /**
29
+ * 解析 managed 文档目录(#296 第二批)。`configured` 为空 = 跟随 memoryDir 的
30
+ * `<memoryDir>/documents/`;`~` / `~/` / `~\` 展开到用户 home;相对路径落在
31
+ * memoryDir 下(本键自己的规则:memoryDir 的相对路径原样留着走 cwd 语义);
32
+ * 绝对路径归一化后原样用。纯函数,路径解析集中在 index.js 调用一次。
33
+ */
34
+ export function resolveDocumentDir(memoryDir, configured = "") {
35
+ const raw = String(configured ?? "").trim();
36
+ if (!raw) return resolve(memoryDir, DOCUMENT_DIR_NAME);
37
+ if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) {
38
+ return resolve(homedir(), raw.slice(2));
39
+ }
40
+ return isAbsolute(raw) ? resolve(raw) : resolve(memoryDir, raw);
41
+ }
42
+
43
+ /**
44
+ * 这个路径是不是落在 managed 目录里(`documentDir` 内 = mneme 管的树,`index.md`
45
+ * 与镜像 md 同权;之外 = 只登记指针行、正文一个字节都不碰)。比较前两边都归一化,
46
+ * Windows 上大小写不敏感(文件系统就是),否则 `C:\a\documents\x.md` 与
47
+ * `c:\A\Documents\x.md` 会被判成两类。
48
+ */
49
+ export function isManagedDocumentPath(dir, path) {
50
+ if (!dir || !path) return false;
51
+ const norm = (p) => (process.platform === "win32" ? String(p).toLowerCase() : String(p));
52
+ const base = norm(resolve(dir));
53
+ const target = norm(resolve(path));
54
+ const prefix = base.endsWith(sep) ? base : base + sep;
55
+ return target === base || target.startsWith(prefix);
56
+ }
26
57
 
27
58
  // 追加到被取代行正文的指针注记。刻意不带语言分支:id 是跨语言稳定键(用户
28
59
  // 对 tag/章节名建议用英文同理——指针性内容不翻译);可追溯链 = 注记里的新行
29
60
  // id + 旧行 content_history 里的旧摘要(source=superseded)。
30
61
  const supersededByPointer = (id) => `\n\n[superseded by ${id}]`;
31
62
 
63
+ // 升格吸收不碰的类型(#275 拍板 5 的边界):吸收的对象是「原子条」,而下面这两种
64
+ // 行各有自己的生命周期,被归档会各自留下第二行——
65
+ // document:本体是磁盘上的文件。归档它等于替用户撤下外档;而且 supersede 探测
66
+ // 只看活跃行(store.list 默认排除归档),同一 path 下次注册会在旧行还挂着
67
+ // 「新版本」语义时再铸一行,指针注记与 content_history 记账也走不到。
68
+ // summary:dream 总览与叙述按 source 身份去重、并按注入档位常驻。归档它会让下一
69
+ // 次做梦把它当不存在(候选集同样只取活跃行),于是库里多出一行同源总览。
70
+ // 口径是保守方向的:拿不准的行一律不吸收——少做一步没有代价,收错一步是内容事故。
71
+ const ABSORB_EXEMPT_TYPES = new Set(["document", "summary"]);
72
+
32
73
  /**
33
74
  * Build the document registrar. Injected deps are service.js closure members
34
75
  * (store / embedQuery / transaction / finalize) so this module stays free of
35
76
  * service-internal wiring; `finalize` is the single write epilogue (mirror
36
77
  * sync + notify + re-embed) so callers never duplicate it.
37
78
  *
38
- * @returns {(payload: object) => Promise<object>}
79
+ * `pinnedTypes` 是注入的 #249 逐字保真池(service.js 的 PINNED_MEMORY_TYPES):
80
+ * 这里不 import 它在 service.js 里,是因为 service.js 反向 import 本模块——
81
+ * 方向反了会成环。
82
+ *
83
+ * @returns {(payload: object, opts?: object) => Promise<object>}
39
84
  * `{ action: "created"|"superseded", memory, superseded?, evidence_kept,
40
- * evidence_dropped, degraded }`
85
+ * evidence_dropped, evidence_archived, degraded }`
41
86
  * @throws flag 关 / 路径非法或文件缺失 / title 或 summary 为空 / evidence 全部
42
87
  * 捏造 / 仅 vector 近重复(疑似重复外档,交 agent 裁决而不是静默二选一)。
43
88
  */
44
- export function createDocumentRegistrar({ store, config, embedQuery, pushContentHistory, transaction, finalize }) {
45
- return async function registerDocument(payload, { hiddenEvidenceIds = [] } = {}) {
89
+ export function createDocumentRegistrar({ store, config, embedQuery, pushContentHistory, transaction, finalize, pinnedTypes }) {
90
+ return async function registerDocument(payload, { hiddenEvidenceIds = [], archiveEvidence = true } = {}) {
46
91
  // opt-in 总闸(#230 对齐 #228 形态):关 = 注册入口整体不存在,错误信息
47
92
  // 指回配置键,agent 能准确转告用户去开。
48
93
  if (config?.documentMemoryEnabled !== true) {
@@ -90,15 +135,14 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
90
135
  const hidden = new Set((Array.isArray(hiddenEvidenceIds) ? hiddenEvidenceIds : []).map((id) => String(id)));
91
136
  const kept = [];
92
137
  const dropped = [];
138
+ // 存在但已归档的引用另记一份:它可能是被上一版文档升格吸收走的行(见下方
139
+ // supersede 目标探测后的「认回」),不能一律按「不可用」处理。
140
+ const archivedRefs = new Set();
93
141
  for (const id of wanted) {
94
142
  const row = hidden.has(id) ? null : store.getById(id);
143
+ if (row?.archived) archivedRefs.add(id);
95
144
  (row && !row.archived ? kept : dropped).push(id);
96
145
  }
97
- if (wanted.length > 0 && kept.length === 0) {
98
- throw new Error(
99
- `registerDocument: all ${wanted.length} evidence ids are unknown, archived or out of scope — fabricated evidence is rejected`
100
- );
101
- }
102
146
 
103
147
  const title = String(payload?.title ?? "").trim();
104
148
  if (!title) throw new Error("registerDocument: title is required");
@@ -131,6 +175,25 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
131
175
  .filter((m) => scopeMatches(m));
132
176
  const explicitTarget = scopeMatched.find((m) => samePath(m.doc_path, expanded))
133
177
  ?? scopeMatched.find((m) => m.title.trim() === title);
178
+ // 升格吸收过的引用认回(#275 拍板 5 的配套):evidence 行在文档注册成功后就翻了
179
+ // archived,于是「同一份文档出新版、evidence 照旧」这一最常规的路径会整批落在
180
+ // dropped——那会被下面的捏造判据误报成「证据都是编的」。口径:已归档行仍可为
181
+ // **吸收它的那份文档**背书(loser.evidence 就是它吸收走的名单),不为别的文档
182
+ // 背书;捏造与跨 scope 照旧拒绝。
183
+ if (explicitTarget) {
184
+ const own = new Set(explicitTarget.evidence ?? []);
185
+ for (const id of archivedRefs) {
186
+ if (!own.has(id)) continue;
187
+ const at = dropped.indexOf(id);
188
+ if (at >= 0) dropped.splice(at, 1);
189
+ kept.push(id);
190
+ }
191
+ }
192
+ if (wanted.length > 0 && kept.length === 0) {
193
+ throw new Error(
194
+ `registerDocument: all ${wanted.length} evidence ids are unknown, archived or out of scope — fabricated evidence is rejected`
195
+ );
196
+ }
134
197
  if (!explicitTarget) {
135
198
  // vector 档:embedder 不可用/向量缺失一律跳过——去重是增强不是写入依赖
136
199
  // (findSessionDuplicate 同原则)。probe 用 title+summary,与行向量
@@ -206,7 +269,25 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
206
269
  store.setArchived(loser.id, true);
207
270
  superseded = store.getById(loser.id);
208
271
  }
209
- return { created, superseded };
272
+ // #275 拍板 5(#230 验收口径的补丁):升格吸收的 evidence 行随之退出活跃面——
273
+ // 一次吸收 20 条,库里就是「21 行不是 1 行」。四条口径:①与本行同一事务(要么
274
+ // 都成、要么都不成);②pinned(constraint / preference,即 #249 的逐字保真池)
275
+ // 永不自动归档——升格不能绕过它;③自有生命周期的类型(document / summary,见
276
+ // ABSORB_EXEMPT_TYPES)不吸收,吸收的是原子条;④只翻标志位、内容与审计全留
277
+ // (可恢复),opt-out 走 archiveEvidence。
278
+ // pinnedTypes 拿不到就不做这一步:宁可少做,也不能把保真池当普通行收走。
279
+ let evidenceArchived = 0;
280
+ if (archiveEvidence && pinnedTypes && typeof pinnedTypes.has === "function") {
281
+ for (const id of kept) {
282
+ // 事务内重读:期间被别的进程归档/遗忘的行不重复计数,也不误伤 pinned。
283
+ const row = store.getById(id);
284
+ if (!row || row.archived || row.forgotten) continue;
285
+ if (pinnedTypes.has(row.type) || ABSORB_EXEMPT_TYPES.has(row.type)) continue;
286
+ store.setArchived(id, true);
287
+ evidenceArchived += 1;
288
+ }
289
+ }
290
+ return { created, superseded, evidenceArchived };
210
291
  });
211
292
  finalize(result.superseded ? [result.created, result.superseded] : [result.created]);
212
293
  return {
@@ -215,6 +296,7 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
215
296
  ...(result.superseded ? { superseded: result.superseded } : {}),
216
297
  evidence_kept: kept.length,
217
298
  evidence_dropped: dropped.length,
299
+ evidence_archived: result.evidenceArchived,
218
300
  degraded: dropped.length > 0
219
301
  };
220
302
  };
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
@@ -1,20 +1,27 @@
1
1
  import { createStore } from "./store.js";
2
2
  import { createMirror, TYPE_FILE } from "./mirror.js";
3
+ import { createDocumentIndex } from "./document-index.js";
4
+ import { resolveDocumentDir } from "./document.js";
3
5
  import { createService } from "./service.js";
6
+ // #254 写入准入(第一阶段只计量,不拦截):见 src/write-admission.js 的文件头。
7
+ import { createWriteAdmission } from "./write-admission.js";
4
8
  import { createTools } from "./tools.js";
5
9
  import { createInjector } from "./inject.js";
10
+ import { createContinuityRescue } from "./continuity.js";
6
11
  import { createSummarizer } from "./summarize.js";
7
12
  import { createDreamScheduler } from "./dream.js";
8
13
  import { createSleepScheduler, runSleep } from "./dream/sleep.js";
9
14
  import { createApi } from "./api.js";
10
15
  import { createStandaloneApi } from "./api-standalone.js";
16
+ // #275 存储生命周期第一批:无损回收(手动入口,不挂启动路径)。
17
+ import { createMaintenance } from "./maintenance.js";
11
18
  import { createSettings } from "./settings.js";
12
19
  import { createCommandManager } from "./commands.js";
13
20
  import { createEmbedder } from "./embedding.js";
14
21
  import { createEmbedderByProvider } from "./local-embedder.js";
15
22
  import { LocalReranker } from "./reranker.js";
16
23
  import { createVectorIndex } from "./vector-index.js";
17
- import { Config, applyLightModePreset } from "./config.js";
24
+ import { Config, applyLightModePreset, injectChildEnabled } from "./config.js";
18
25
  import { langOf } from "./lang.js";
19
26
  import { extractEntities } from "./entities/extractor.js";
20
27
  import { mkdirSync } from "node:fs";
@@ -193,15 +200,6 @@ export const apply = (ctx, config) => {
193
200
  try {
194
201
  store.deleteOldFailures(new Date(Date.now() - 90 * 86400000).toISOString());
195
202
  } catch { /* non-fatal */ }
196
- // Bug8: enforce llm_audit_logs retention on boot (config.llmAudit.retentionDays,
197
- // default 90). Best-effort like the failure prune — the audit trail is
198
- // bookkeeping and a failed purge must never block plugin boot.
199
- try {
200
- if (rawCfg.llmAudit?.enabled !== false) {
201
- const retentionMs = Number.isInteger(rawCfg.llmAudit?.retentionDays) ? rawCfg.llmAudit.retentionDays : 90;
202
- store.deleteOldLlmAudits(new Date(Date.now() - retentionMs * 86400000).toISOString());
203
- }
204
- } catch { /* non-fatal */ }
205
203
 
206
204
  // User-configurable settings (profile, rules, panel mode, standalone API
207
205
  // token) share the same SQLite file in dedicated tables, isolated from
@@ -240,10 +238,46 @@ export const apply = (ctx, config) => {
240
238
  cfg[objKey] = { ...(cfg[objKey] ?? {}), ...sub };
241
239
  }
242
240
 
241
+ // Bug8 的启动期清理放在装配之后:面板把 llmAudit.* 写进 kv、经 nestedFlags 合进
242
+ // cfg,而写入侧(dream / summarize / 写入准入)读的都是装配后的 cfg——清理若读
243
+ // rawCfg,面板改了保留期它不认,更糟的是「开不开审计」与写入侧可能取到不同的值
244
+ // (raw 说关 → 不清理,cfg 说开 → 照写,审计表就无保留期地长)。保留期默认 90 天、
245
+ // 失败只 warn,与失败表清理同款:账本清理绝不许挡住插件启动。
246
+ try {
247
+ if (cfg.llmAudit?.enabled !== false) {
248
+ const retentionMs = Number.isInteger(cfg.llmAudit?.retentionDays) ? cfg.llmAudit.retentionDays : 90;
249
+ store.deleteOldLlmAudits(new Date(Date.now() - retentionMs * 86400000).toISOString());
250
+ }
251
+ } catch { /* non-fatal */ }
252
+
243
253
  // 记忆语言(memory.language):本实例逐层传入 inject / summarize / dream /
244
254
  // sleep / mirror,多实例(如 agent preset 内挂载)互不影响。
245
255
  const mirror = createMirror(memoryDir, langOf(cfg));
246
- const service = createService({ store, mirror, config: cfg, logger: ctx.logger });
256
+
257
+ // #296 第二批:managed 文档目录。解析与 memoryDir 同一套(空 = 跟随 memoryDir 的
258
+ // <memoryDir>/documents/,~ 展开,相对路径落在 memoryDir 下)。索引对象总是建:
259
+ // 闸关时它只承担「清掉陈旧 index.md」这一件事,与 documents.md 在闸关时被镜像删
260
+ // 掉同一口径。建目录与写索引失败都只 warn,不阻断插件加载(它们是机器产物)。
261
+ const documentDir = resolveDocumentDir(memoryDir, cfg.documentDir);
262
+ const documentIndex = createDocumentIndex(documentDir, langOf(cfg));
263
+ if (cfg.documentMemoryEnabled === true) {
264
+ const ensured = documentIndex.ensure();
265
+ if (!ensured.ok) {
266
+ ctx.logger?.warn?.(`[dsh-mneme] documentDir is not writable: ${documentDir}: ${ensured.error}`);
267
+ }
268
+ // 启动即渲染一次:索引不该等到第一次业务写才存在(删掉它之后重启也能自愈)。
269
+ const synced = documentIndex.sync(store.list({ type: "document", limit: null }));
270
+ if (!synced.ok) ctx.logger?.warn?.(`[dsh-mneme] document index sync failed: ${synced.error}`);
271
+ } else {
272
+ documentIndex.remove();
273
+ }
274
+
275
+ // 写入准入实例:本批次只做计量(决策恒放行、写审计行),所以不需要新开关——它
276
+ // 不改变任何写入行为,也不新增拦截分支;既有的 llmAudit.enabled 关掉时它同样
277
+ // 不写(那个开关连审计行的启动期清理一起关掉)。第二阶段把拦截打开时才按仓库
278
+ // 惯例引入 opt-in 默认关的配置键,届时只改这一个实例的构造与 service 的调用点。
279
+ const writeAdmission = createWriteAdmission({ store, config: cfg, logger: ctx.logger });
280
+ const service = createService({ store, mirror, config: cfg, logger: ctx.logger, documentIndex, writeAdmission });
247
281
 
248
282
  // F-NEW-03: if the mirror sync failed last run (persisted dirty state), retry
249
283
  // a safe re-render at boot so a stale mirror converges without needing a
@@ -457,9 +491,37 @@ export const apply = (ctx, config) => {
457
491
  thresholdChars: cfg.dreamThresholdChars,
458
492
  delayMs: cfg.dreamDelayMs,
459
493
  minIntervalMs: (cfg.dreamMinIntervalMinutes ?? 0) * 60000,
494
+ // Issue #292:连续失败指数退避(opt-in,默认关 = 行为不变)。
495
+ failureBackoff: cfg.autoDreamFailureBackoff === true,
460
496
  logger: ctx.logger,
461
497
  semantic: { embedder, vectorIndex },
462
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
+ },
463
525
  onRun: () => (dream ? dream.runDream(ctx, service, cfg) : Promise.resolve({ ok: true, skipped: true }))
464
526
  });
465
527
  service.setDreamHook(() => dream.maybeSchedule(service));
@@ -526,6 +588,13 @@ export const apply = (ctx, config) => {
526
588
  if (cfg.autoInject) disposers.push(createInjector(promptCtx, service, settings, cfg));
527
589
  });
528
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
+
529
598
  ctx.inject(["tools"], (toolsCtx) => {
530
599
  disposers.push(createTools(toolsCtx, service, cfg, embedder));
531
600
  });
@@ -549,13 +618,19 @@ export const apply = (ctx, config) => {
549
618
  disposers.push(api.dispose);
550
619
  }
551
620
 
621
+ // #275 存储生命周期第一批:无损回收的手动入口。刻意不挂启动路径、不接定时器——
622
+ // 这一步是不可逆的内容丢弃,只由人显式触发(`dsh-mneme reclaim`)。实例在这里建、
623
+ // 在 standalone API 上暴露,是为了让 CLI 能在插件进程内跑(VACUUM 要排他锁,跟宿主
624
+ // 抢锁的那条路走不通)。
625
+ const maintenance = createMaintenance({ store, config: cfg, logger: ctx.logger });
626
+
552
627
  // Standalone external API (v0.7.12): plain node:http server for ecosystem
553
628
  // integrations outside the DSH host. Persisted external_api settings win
554
629
  // over the bundle config (enabled/port); the Bearer token lives in the same
555
630
  // kv and is auto-generated on first boot by createStandaloneApi. Binding a
556
631
  // non-loopback host is the operator's documented responsibility.
557
632
  if ((settings.getExternalApi?.()?.enabled ?? cfg.externalApiEnabled) === true) {
558
- const standalone = createStandaloneApi({ service, store, config: cfg, logger: ctx.logger, settings });
633
+ const standalone = createStandaloneApi({ service, store, config: cfg, logger: ctx.logger, settings, maintenance });
559
634
  disposers.push(() => standalone.server.close());
560
635
  standalone.ready.catch((error) => {
561
636
  ctx.logger?.warn?.(`[dsh-mneme] standalone API failed to start: ${String(error)}`);
package/lib/inject.js CHANGED
@@ -3,6 +3,7 @@ import { createHotMemory } from "./hot-memory.js";
3
3
  import { STR, langOf } from "./lang.js";
4
4
  import { MEMORY_GUIDE_SECTION } from "./guide.js";
5
5
  import { adaptiveInjectBudget } from "./search/adaptive.js";
6
+ import { injectChildEnabled } from "./config.js";
6
7
 
7
8
  // Issue #179:注入预览的数据底座。systemPrompt 渲染是同步回调,面板只能事后
8
9
  // 拉取,所以在这里旁路缓存「最近一帧组装」——快照就是本次渲染用过的同一份
@@ -283,12 +284,15 @@ export function createInjector(ctx, service, settings, config) {
283
284
  return escapePromptVars(lines.join("\n"));
284
285
  }
285
286
 
286
- // #249 第一批:能力说明的系统提示段。order 150 = 插件指引段的既有惯例
287
+ // #249 能力说明的系统提示段。order 150 = 插件指引段的既有惯例
287
288
  // (ACP 的 ACP_SYSTEM_PROMPT 与 mnemon 的 routing 段都在 150)。文本是常量、
288
289
  // 同会话内不随轮次变化,故不影响其后的前缀缓存,也不逐轮复读。宿主若不提供
289
290
  // section seam 就静默跳过——#249 §3 的口径是「只用现成 seam,拿不到位的语义
290
291
  // 降级处理」:能力说明仍落在工具描述上,不算失败。
291
- const guideSection = config.injectGuidanceEnabled === true && typeof ctx.systemPrompt?.section === "function"
292
+ // 走父/子闸门(#249 第二批):注入器本身只在 `autoInject` 开时挂载,这里是
293
+ // 第二道、也是同一个判据的显式落点——两处消费点共用 injectChildEnabled,
294
+ // 不在各自的地方重写「父关则子不生效」。
295
+ const guideSection = injectChildEnabled(config, "injectGuidanceEnabled") && typeof ctx.systemPrompt?.section === "function"
292
296
  ? ctx.systemPrompt.section({ name: "memory-guide", order: 150, text: MEMORY_GUIDE_SECTION })
293
297
  : null;
294
298
 
package/lib/lang.js CHANGED
@@ -196,12 +196,23 @@ export const STR = {
196
196
 
197
197
  // --- mirror.js:镜像文件标签(渲染随实例语言;解析两种语言都认) ----------------
198
198
  mirrorLabel: {
199
- zh: { type: "类型", importance: "重要性", tags: "标签", updated: "更新时间", source: "来源" },
200
- en: { type: "Type", importance: "Importance", tags: "Tags", updated: "Updated", source: "Source" }
199
+ zh: { type: "类型", importance: "重要性", tags: "标签", updated: "更新时间", source: "来源", scope: "作用域", sensitivity: "敏感度" },
200
+ en: { type: "Type", importance: "Importance", tags: "Tags", updated: "Updated", source: "Source", scope: "Scope", sensitivity: "Sensitivity" }
201
201
  },
202
202
  mirrorHeader: {
203
- zh: (name) => `# ${name} — dsh-mneme 镜像\n\n<!-- 手工编辑此文件会被合并回记忆库(人工优先)。 -->\n\n`,
204
- en: (name) => `# ${name} — dsh-mneme mirror\n\n<!-- Manual edits to this file are merged back into the memory store (human edits win). -->\n\n`
203
+ zh: (name) => `# ${name} — dsh-mneme 镜像\n\n<!-- 条目标题与正文可编辑,会被合并回记忆库(人工优先);文件头与条目元数据行由机器维护,改动会在下次同步时被覆盖。 -->\n\n`,
204
+ en: (name) => `# ${name} — dsh-mneme mirror\n\n<!-- Entry titles and bodies are editable and merged back into the memory store (human edits win); the file header and entry metadata lines are machine-owned and get overwritten on the next sync. -->\n\n`
205
+ },
206
+ // document 的镜像只有指针行(#296 第二批):没有可编辑的正文,手工改动一律被
207
+ // 下次同步覆盖——所以不能复用上面那句「可编辑、会被合并回记忆库」。
208
+ mirrorReadonlyHeader: {
209
+ zh: (name) => `# ${name} — dsh-mneme 只读视图\n\n<!-- 只含指针行:id + 标题 + 摘要首句 + 文件路径,不含正文。正文在路径指向的文件里,批注请写进记忆库;本文件由机器维护,手工改动会在下次同步时被覆盖。 -->\n\n`,
210
+ en: (name) => `# ${name} — dsh-mneme read-only view\n\n<!-- Pointer rows only: id + title + first sentence of the summary + file path, never the full text. The document itself lives at that path, annotations belong in the memory store, and this file is machine-owned: hand edits are overwritten on the next sync. -->\n\n`
211
+ },
212
+ // documentDir 的 index.md(#296 第二批):整文件机器所有、可从库重建。
213
+ documentIndexHeader: {
214
+ zh: () => "# document 索引 — dsh-mneme\n\n<!-- 整文件机器所有,可从记忆库随时重建(所以这里不写生成时间);要批注请写进记忆库(memory_save)。managed = 文件在 documentDir 内。 -->\n\n",
215
+ en: () => "# document index — dsh-mneme\n\n<!-- Machine-owned as a whole and rebuildable from the memory store at any time (which is why it carries no generation timestamp). Annotations belong in the memory store (memory_save). managed = the file sits inside documentDir. -->\n\n"
205
216
  },
206
217
 
207
218
  prompts: PROMPTS