@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
@@ -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
+ }
@@ -5,6 +5,8 @@
5
5
  // 新内聚块从第一行就落在自己的文件里,service.js 保留 barrel 出口、调用方零改动。
6
6
  // 纯读不写,绝不触发任何 write hook。
7
7
 
8
+ import { scopeKeyOf } from "./scope.js";
9
+
8
10
  /**
9
11
  * 口径(issue #217 评论 2026-09-18,锚 5bd2dab):
10
12
  * - Top-N:窗口内 recall_runs.candidates(最终返回集)按 id 计数,join
@@ -20,6 +22,17 @@
20
22
  * - coverage:recallRecordDefault 开启前的窗口算不到,earliestRunAt 为
21
23
  * null(窗口内无回执)或早于窗口起点时,前端标注可信度;扫描行数有
22
24
  * 上限,超出标 truncated(degraded 口径),不静默少算。
25
+ * - archive(#275 拍板的第五指标,与上面几项同位):
26
+ * · total = 归档区现有行数;addedInWindow / perDay = 窗口内进入归档区的行数
27
+ * 与其日均(净增速率)。归档时刻只能取 updated_at(setArchived 翻标志位时刷
28
+ * 它)——这是代理口径:对已归档行做一次 memory_update,或对同一行重复调
29
+ * setArchived(…, true),都会被算成「本窗口新进归档」,perDay 因此偏高。精确
30
+ * 口径要一个 archived_at 列(schema 变更,属第二批题材),本版不加;真正的
31
+ * 「净」增本来也要跨快照比 total(物理删除落地后,差值才会由负向变化体现)。
32
+ * · compressible = 可压掉行数:同 type、同 scope 三维且内容哈希完全相同的归档
33
+ * 行里,多出来的那些(每组留一行)。用内容哈希而不是向量近重复,是因为回收
34
+ * 动作本身会清掉归档行向量(clearArchivedEmbeddings)——指标不能建在它自己
35
+ * 的输入会被回收掉的数据上;精确重复与 #254 的计量口径同一把尺。
23
36
  *
24
37
  * @param {object} store - createStore 产物(只调用 listRecallRunsSince / all)
25
38
  * @param {{ windowDays?: number, exemptDays?: number }} [options]
@@ -36,7 +49,8 @@ export function recallStats(store, options = {}) {
36
49
  const rawSlots = Number(options.maxInjectSlots ?? 5);
37
50
  const maxInjectSlots = Number.isInteger(rawSlots) && rawSlots >= 1 ? rawSlots : 5;
38
51
  const now = Date.now();
39
- const since = new Date(now - windowDays * 86400000).toISOString();
52
+ const sinceMs = now - windowDays * 86400000;
53
+ const since = new Date(sinceMs).toISOString();
40
54
 
41
55
  const { rows: runs, total } = store.listRecallRunsSince(since);
42
56
  const hits = new Map(); // id -> { count, title, source }
@@ -67,8 +81,29 @@ export function recallStats(store, options = {}) {
67
81
  let activeCount = 0;
68
82
  let zombieCount = 0;
69
83
  let exemptCount = 0;
84
+ // 第五指标(#275):归档区一侧单独累计,分组键与 #254 的去重候选集同构
85
+ // (type + 哈希 + scope 三维)——可压掉的必须是「本来就会被判成同一件事」的行。
86
+ let archivedTotal = 0;
87
+ let archivedInWindow = 0;
88
+ const hashGroups = new Map();
70
89
  for (const m of memories) {
71
- if (m.archived || m.forgotten) continue;
90
+ if (m.archived) {
91
+ archivedTotal += 1;
92
+ const archivedMs = Date.parse(m.updated_at ?? "");
93
+ if (Number.isFinite(archivedMs) && archivedMs >= sinceMs) archivedInWindow += 1;
94
+ if (m.content_hash) {
95
+ const key = [
96
+ m.type ?? "",
97
+ m.content_hash,
98
+ scopeKeyOf(m.agent_scope) ?? "",
99
+ scopeKeyOf(m.workspace_scope) ?? "",
100
+ scopeKeyOf(m.sensitivity) ?? ""
101
+ ].join("\u0000");
102
+ hashGroups.set(key, (hashGroups.get(key) ?? 0) + 1);
103
+ }
104
+ continue;
105
+ }
106
+ if (m.forgotten) continue;
72
107
  const createdMs = Date.parse(m.created_at ?? "");
73
108
  // created_at 解析不了时无法证明已过机会期 → 归入豁免,宁漏勿误伤
74
109
  if (!Number.isFinite(createdMs) || now - createdMs < EXEMPT_MS) {
@@ -98,6 +133,14 @@ export function recallStats(store, options = {}) {
98
133
  .sort((a, b) => b.count - a.count || a.id.localeCompare(b.id))
99
134
  .slice(0, 10);
100
135
 
136
+ let compressibleGroups = 0;
137
+ let compressibleRows = 0;
138
+ for (const n of hashGroups.values()) {
139
+ if (n < 2) continue;
140
+ compressibleGroups += 1;
141
+ compressibleRows += n - 1;
142
+ }
143
+
101
144
  return {
102
145
  windowDays,
103
146
  generatedAt: new Date(now).toISOString(),
@@ -113,6 +156,13 @@ export function recallStats(store, options = {}) {
113
156
  slotFillRate: injectRuns > 0 ? injectedCount / (injectRuns * maxInjectSlots) : null
114
157
  },
115
158
  topRecalled,
159
+ // #275 第五指标:归档净增速率 + 可压掉行数(口径见文件头)
160
+ archive: {
161
+ total: archivedTotal,
162
+ addedInWindow: archivedInWindow,
163
+ perDay: Math.round((archivedInWindow / windowDays) * 100) / 100,
164
+ compressible: { rows: compressibleRows, groups: compressibleGroups }
165
+ },
116
166
  zombie: {
117
167
  activeCount,
118
168
  zombieCount,
package/src/scope.js CHANGED
@@ -34,12 +34,7 @@ export function createScopeResolver({ ctx, config, logger } = {}) {
34
34
  };
35
35
 
36
36
  return function resolveWriteScope(exec) {
37
- let session = null;
38
- try {
39
- session = exec?.agent?.session ?? null;
40
- } catch {
41
- session = null;
42
- }
37
+ const session = sessionOf(exec);
43
38
 
44
39
  // 持久化 header(issue #17 的身份契约):session.header 是创建时冻结的
45
40
  // SessionHeader(含 agentPreset/cwd)。⚠️ 不能先读 requestHeader()——那返回
@@ -83,6 +78,39 @@ export function createScopeResolver({ ctx, config, logger } = {}) {
83
78
  };
84
79
  }
85
80
 
81
+ /**
82
+ * 工具 exec 上的会话对象。宿主 ctx 是 Proxy(cordis 4),裸读未 inject 的属性会
83
+ * 抛错——所以取值恒包在 try 里,取不到返回 null。scope 解析与写入准入的会话键
84
+ * 共用这一处取值口径。
85
+ * @param {object} exec
86
+ * @returns {object|null}
87
+ */
88
+ export function sessionOf(exec) {
89
+ try {
90
+ return exec?.agent?.session ?? null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * 写入准入的会话键(#254 G1 会话写入预算的计数单位)。session_id 在这里只作
98
+ * 「同一段对话」的标识,不作为作用域键(见文件头:记忆挂在持久化身份标签上);
99
+ * 取不到会话身份(系统写入)返回 null = 不进预算。
100
+ * @param {object} exec
101
+ * @returns {string|null}
102
+ */
103
+ export function sessionKeyOf(exec) {
104
+ // 整个取值(含 .id)都在 try 里:宿主 session 可能是会抛错的 accessor,而这个
105
+ // 函数在 memory_save 的保存之前被调用——抛出去就是一次工具调用直接失败。
106
+ try {
107
+ const id = sessionOf(exec)?.id;
108
+ return id == null ? null : String(id);
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
86
114
  /**
87
115
  * v0.8.1 底座(issue #170):显式 scope 参数(memory_save / memory_update /
88
116
  * 面板编辑)的归一化。约定:
package/src/service.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { TYPE_FILE } from "./mirror.js";
2
+ import { TYPE_FILE, MIRROR_READONLY_TYPES } from "./mirror.js";
3
3
  import { updatedAtBounds } from "./store.js";
4
4
  import { normalizeExplicitScope, scopeKeyOf } from "./scope.js";
5
5
  import { STR, langOf } from "./lang.js";
@@ -29,7 +29,9 @@ const CODING_MEMORY_TYPES = new Set(["rejected_solution", "pitfall", "constraint
29
29
  // 注意 constraint 同时属于 CODING_MEMORY_TYPES,非编码任务里已被 codingGate
30
30
  // 滤掉,pin 池同样拿不到它;是否豁免该门控是待维护者拍板的口径问题,本批次
31
31
  // 不动既有门控(不放大行为面)。
32
- const PINNED_MEMORY_TYPES = new Set(["constraint", "preference"]);
32
+ // 导出给写入准入(#254 的穿透口)与注入侧共用一份定义:两处各写一份 list 迟早
33
+ // 漂移成「pin 池豁免了、预算没豁免」这类两套口径。
34
+ export const PINNED_MEMORY_TYPES = new Set(["constraint", "preference"]);
33
35
 
34
36
  /**
35
37
  * 判断一段文本是否编码类任务(关键词匹配,codingRetrospect 读取侧门控)。
@@ -167,7 +169,7 @@ export function computeRetrievalMetrics(actualIds, expectedIds) {
167
169
  };
168
170
  }
169
171
 
170
- export function createService({ store, mirror, config, onWrite, logger }) {
172
+ export function createService({ store, mirror, config, onWrite, logger, documentIndex, writeAdmission }) {
171
173
  const language = langOf(config);
172
174
  // Optional dream scheduler hook, installed via setDreamHook after creation
173
175
  // (the scheduler holds a reference back to the service, so it cannot be
@@ -876,12 +878,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
876
878
  mode,
877
879
  topK: lim,
878
880
  threshold: threshold ?? null,
881
+ // Per-source 信号随回执落盘(fuseRecall 本就无条件计算,此前只服务
882
+ // signalTransparency 展示):复用侧查询画像(query 型 → 各路权重的
883
+ // 离线聚合,AssoMem arXiv 2510.10397 消融所示收益大头)需要这份原料。
884
+ // 纯加字段——recall_runs 无 schema 变更,旧行 candidates 缺该键照常读。
879
885
  candidates: result.map((m) => ({
880
886
  id: m.id,
881
887
  title: m.title,
882
888
  content: m.content,
883
889
  score: m.score ?? null,
884
- source: m.source ?? "keyword"
890
+ source: m.source ?? "keyword",
891
+ signals: signals.get(m.id) ?? {}
885
892
  })),
886
893
  createdAt: new Date().toISOString()
887
894
  });
@@ -1243,6 +1250,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1243
1250
  scheduleEmbed(result);
1244
1251
  return { action: "merged", memory: result };
1245
1252
  }
1253
+ // #254 写入准入(第一阶段只计量):决策恒 allow、不拦写入,只把两个闸门的
1254
+ // 测量点算出来。放在 store.save 之前——第二阶段要在这里拦下写入,接缝先摆好。
1255
+ // 计量是旁路:任何一环失败只 warn,绝不反噬写入(与质量打分同款容错)。
1256
+ let admission = null;
1257
+ if (writeAdmission) {
1258
+ try {
1259
+ admission = writeAdmission.evaluate({ memory, sessionKey: memory._sessionKey });
1260
+ } catch (e) {
1261
+ try { logger?.warn?.(`[dsh-mneme] write admission evaluate failed: ${String(e)}`); } catch { /* 不反噬 */ }
1262
+ }
1263
+ }
1246
1264
  const created = store.save({
1247
1265
  type: memory.type,
1248
1266
  title: memory.title,
@@ -1263,6 +1281,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1263
1281
  ...(quality ? { quality_score: quality.score } : {})
1264
1282
  });
1265
1283
  const result = applyQualityDisposition(created, quality, qf);
1284
+ if (admission) {
1285
+ try {
1286
+ writeAdmission.record({
1287
+ sessionKey: memory._sessionKey,
1288
+ verdict: admission,
1289
+ memoryId: created.id
1290
+ });
1291
+ } catch (e) {
1292
+ try { logger?.warn?.(`[dsh-mneme] write admission record failed: ${String(e)}`); } catch { /* 不反噬 */ }
1293
+ }
1294
+ }
1266
1295
  afterSync("write");
1267
1296
  notifyWrite();
1268
1297
  scheduleEmbed(result);
@@ -1560,6 +1589,10 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1560
1589
  * Only content/title are taken; structure fields stay machine-owned.
1561
1590
  */
1562
1591
  function mergeHumanEdits(type, edits) {
1592
+ // #296:只读 type 的结构性守卫。调用点(启动合并 / reconcileHumanEdits)都已
1593
+ // 跳过它们,但这条不变量属于「人改回填」本身——将来多一个调用点不该重开这个口
1594
+ // (指针行文本被当正文写回 document 行会污染摘要)。
1595
+ if (MIRROR_READONLY_TYPES.has(type)) return 0;
1563
1596
  let applied = 0;
1564
1597
  for (const edit of edits) {
1565
1598
  if (!edit.id) continue; // corrupt/malformed edit: skip it, keep merging the rest
@@ -1787,6 +1820,26 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1787
1820
  * non-forgotten memories are mirrored: forgotten entries must not reach the
1788
1821
  * human-editable file (a human "edit" could otherwise resurrect them).
1789
1822
  */
1823
+ // #296 第二批:documentDir/index.md 的写后语。与注册入口共用同一个
1824
+ // documentMemoryEnabled 闸(默认关):闸开时只列活跃指针行(正文永远不进这个
1825
+ // 文件),闸关时把索引删掉——document 子系统整体退出,留一份陈旧索引会列出已
1826
+ // 归档的行,正是镜像侧 documents.md 在闸关时被删掉要避免的那种「看着还在、其实
1827
+ // 已关」的视图。两步都只 warn:索引是机器产物,不能让触发它的业务写失败。
1828
+ function syncDocumentIndex() {
1829
+ if (!documentIndex) return;
1830
+ if (config?.documentMemoryEnabled !== true) {
1831
+ const removed = documentIndex.remove();
1832
+ if (!removed?.ok) logger?.warn?.("document index remove failed:", removed?.error);
1833
+ return;
1834
+ }
1835
+ try {
1836
+ const result = documentIndex.sync(store.list({ type: "document", limit: null }));
1837
+ if (!result?.ok) logger?.warn?.("document index sync failed:", result?.error);
1838
+ } catch (error) {
1839
+ logger?.warn?.("document index sync failed:", error);
1840
+ }
1841
+ }
1842
+
1790
1843
  // syncMirror: 同步 mirror,并在失败/成功时持久记录 dirty 状态;保证自身不抛出。
1791
1844
  // v0.3.6(audit peer 4 阻断):
1792
1845
  // - 开始时 incrementGeneration 绑定本次期望轮次 gen;成功用
@@ -1796,7 +1849,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1796
1849
  // - 逐 type 用 setTypeStatus 记录部分成功/失败(type_status JSON);
1797
1850
  // - 所有 store 状态写入各自 try/catch,失败只 warn,绝不向外抛(F-NEW-03)。
1798
1851
  function syncMirror() {
1799
- if (txDepth > 0 || !mirror) return { success: true, deferred: true }; // deferred to the transaction's commit
1852
+ if (txDepth > 0) return { success: true, deferred: true }; // deferred to the transaction's commit
1853
+ // #296 第二批:documentDir/index.md 走同一条写后语——它与镜像一样是「从库渲染
1854
+ // 的机器产物」,同样只在内容变化时落盘、同样自己吞掉失败。放在 !mirror 短路
1855
+ // 之前:索引在不在,不该取决于镜像是否装配(无镜像的宿主与测试同样要有它)。
1856
+ syncDocumentIndex();
1857
+ if (!mirror) return { success: true, deferred: true };
1800
1858
  const now = new Date().toISOString();
1801
1859
  let gen;
1802
1860
  try {
@@ -1812,8 +1870,20 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1812
1870
  // 合法的空 Set 可迭代,保证 syncMirror 自身绝不抛(fail-safe)。
1813
1871
  const coveredTypes = new Set();
1814
1872
  try {
1815
- // 预先获取本次要覆盖的 type 集合(只调一次 store.list)
1816
- const list = store.list({ limit: 500, includeForgotten: false });
1873
+ // 预先取本次要覆盖的全部活跃行(只读一次)。这里曾经是
1874
+ // store.list({ limit: 500 })——活跃集超过 500 时镜像会静默少掉尾部记忆,
1875
+ // 而文件本身没有任何提示(#278 第一批)。store.list 的 limit 默认值只有
1876
+ // 50,比原来显式传的 500 更小,所以改用 all() 并在这里做同一套过滤
1877
+ // (forgotten/archived 都不进镜像,与 includeForgotten:false + 默认
1878
+ // includeArchived:false 等价)。
1879
+ // 代价是全表读,且落在每次业务写后的最热路径上(#202 自记 all() 5k 行
1880
+ // 231ms → ~135ms)。要压这一层得换按 type 分页取,属另一批的事;
1881
+ // 在这里退回任何截断都不行——「宣称覆盖活跃集」与静默截断不能共存。
1882
+ const list = store.all().filter((m) => !m.forgotten && !m.archived
1883
+ // #296 第二批:镜像里的 documents.md 与 index.md 共用 documentMemoryEnabled
1884
+ // 闸。关掉时把 document 行也从渲染集里去掉——sync 对「空 type」的既有处理
1885
+ // 会把陈旧的 documents.md 删掉,不留一个「看着还在、其实已关」的视图。
1886
+ && (m.type !== "document" || config?.documentMemoryEnabled === true));
1817
1887
  for (const memory of list) {
1818
1888
  if (memory?.type && TYPE_FILE[memory.type]) {
1819
1889
  coveredTypes.add(memory.type);
@@ -2092,6 +2162,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
2092
2162
  function archiveMemory(id, f) {
2093
2163
  const updated = store.setArchived(id, f);
2094
2164
  afterSync("write");
2165
+ // #275 B 项:归档行的向量会被手动回收清掉(检索恒带 archived = 0,按定义不可达),
2166
+ // 还原回活跃面时补一次嵌入——否则那行只剩关键词可检索,回收就成了单程票。
2167
+ // 判据用真值(`!f`)而不是 `f === false`:store.setArchived 自己就是按真值归一
2168
+ // (`archived ? 1 : 0`),API/工具传 0 或空串同样会把行放回活跃面,口径必须同一把尺。
2169
+ // 排队语义与写入路径同一处(txDepth / 未就绪由 scheduleEmbed 自己挡)。
2170
+ if (!f) scheduleEmbed(updated);
2095
2171
  return updated;
2096
2172
  }
2097
2173
 
@@ -2105,6 +2181,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
2105
2181
  embedQuery,
2106
2182
  pushContentHistory,
2107
2183
  transaction,
2184
+ // #275 拍板 5:升格吸收的 evidence 行随之归档,但 pinned 池(#249)永不自动归档
2185
+ // ——注册器不 import 这个集合,方向反了会成环,所以在这里注入。
2186
+ pinnedTypes: PINNED_MEMORY_TYPES,
2108
2187
  finalize: (rows) => {
2109
2188
  for (const row of rows) scheduleEmbed(row);
2110
2189
  }
package/src/settings.js CHANGED
@@ -45,8 +45,12 @@ const FEATURE_FLAG_BOOLEANS = [
45
45
  "hotMemoryEnabled",
46
46
  // Issue #239 第 5 项:注入条数的查询自适应(确定性强则收缩注入条数,默认关)。
47
47
  "injectUncertaintyAdaptive",
48
- // Issue #249 第一批:能力说明(工具描述判断指引 + order 150 总则段,默认关)。
48
+ // Issue #249:能力说明(工具描述判断指引 + order 150 总则段)。第二批起它是
49
+ // 注入父开关 autoInject 的子项、默认开(父关时不生效,闸门见 config.js)。
49
50
  "injectGuidanceEnabled",
51
+ // Issue #249 N3:压缩边缘双落点——上下文即将精简前落一条连续性提案 + 往
52
+ // 序列末尾追加同一份快照。默认关(新注入表面,见 config.js),父开关 autoInject。
53
+ "continuityRescueEnabled",
50
54
  "entityExtractionEnabled",
51
55
  // Issue #219:图召回轴——查询命中实体名时把挂联记忆并入检索融合池
52
56
  // (默认关;依赖实体抽取产出,lightMode 强制关闭)。
@@ -59,6 +63,9 @@ const FEATURE_FLAG_BOOLEANS = [
59
63
  "documentMemoryEnabled",
60
64
  "codingRetrospect",
61
65
  "autoDream",
66
+ // Issue #292:autoDream 连续失败退避(默认关)。基数是 dreamMinIntervalMinutes,
67
+ // 有效间隔 = 基数 × 2^连续失败数(成功清零),封顶 30 分钟;面板可启停。
68
+ "autoDreamFailureBackoff",
62
69
  "sleepModeEnabled",
63
70
  "heatEnabled",
64
71
  "hybridInject",
@@ -113,6 +120,8 @@ const FEATURE_FLAG_INT_RANGES = {
113
120
  summarizeMaxRunsPerSession: [0, 1000],
114
121
  // Issue #239 第 4 项:高峰顺延上限(分钟,0 = 不设上限)。
115
122
  summarizePeakMaxDeferMinutes: [0, 1440],
123
+ // Issue #239 第 4 项镜像到巩固:同一口径(分钟,0 = 不设上限)。
124
+ dreamPeakMaxDeferMinutes: [0, 1440],
116
125
  // Issue #125:hybrid 候选量上限(0 = 复用 dreamMaxSnapshotSize)。
117
126
  dreamCandidateMax: [0, 5000],
118
127
  // Issue #164①:注入单条正文截断上限(默认 300 = 既有行为)。
@@ -155,7 +164,9 @@ const FEATURE_FLAG_STRINGS = [
155
164
  "localEmbedModel",
156
165
  "ollamaModel",
157
166
  // Issue #239 第 4 项:高峰时段串("09:00-18:00",空串 = 关闭)。
158
- "summarizePeakHours"
167
+ "summarizePeakHours",
168
+ // Issue #239 第 4 项镜像到巩固:同一份时段语法,空串 = 关闭(行为与现状一致)。
169
+ "dreamPeakHours"
159
170
  ];
160
171
  // URL 字符串开关:trim 后必须为空或合法 http/https URL(new URL() 校验协议,
161
172
  // 拒绝其余协议——这是 SSRF 防线的一部分)。
@@ -168,6 +179,9 @@ const FEATURE_FLAG_ENUMS = {
168
179
  recallFusion: ["blend", "rrf", "minmax"],
169
180
  // 实体抽取思考强度(issue #109):与 dreamReasoningEffort 枚举对齐。
170
181
  entityExtractionReasoning: ["low", "medium", "high", "none"],
182
+ // 蒸馏思考强度(issue #315):与 entityExtractionReasoning 枚举对齐,
183
+ // 多一个 off(显式关思考,思考型模型蒸馏防推理烧预算)。
184
+ summarizeReasoningEffort: ["off", "low", "medium", "high", "none"],
171
185
  // Issue #127:落库前去重档位(off 默认,等同现状)。
172
186
  summarizeDedupeMode: ["off", "title", "vector"],
173
187
  // Issue #126:sleep 冲突阶段的动作集(conflict 默认 = 现状;full = 六分支)。