@modusensus/dsh-mneme 0.8.11 → 0.8.12

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 (43) hide show
  1. package/README.md +5 -5
  2. package/assets/icon.svg +3 -3
  3. package/lib/config.js +47 -1
  4. package/lib/dream/decisions.js +16 -0
  5. package/lib/dream/sleep.js +3 -0
  6. package/lib/dream.js +57 -9
  7. package/lib/entities/extractor.js +4 -1
  8. package/lib/graph/anchoring.js +180 -0
  9. package/lib/index.js +5 -0
  10. package/lib/inject.js +15 -3
  11. package/lib/lang.js +10 -0
  12. package/lib/service.js +164 -23
  13. package/lib/settings.js +24 -2
  14. package/lib/store.js +155 -11
  15. package/lib/summarize.js +104 -7
  16. package/locale/en.json +6 -6
  17. package/locale/zh.json +6 -6
  18. package/package.json +7 -6
  19. package/scripts/sync-test-badge.mjs +9 -7
  20. package/scripts/test-count-sync.mjs +44 -0
  21. package/src/config.js +47 -1
  22. package/src/dream/decisions.js +16 -0
  23. package/src/dream/sleep.js +3 -0
  24. package/src/dream.js +57 -9
  25. package/src/entities/extractor.js +4 -1
  26. package/src/graph/anchoring.js +180 -0
  27. package/src/index.js +5 -0
  28. package/src/inject.js +15 -3
  29. package/src/lang.js +10 -0
  30. package/src/service.js +164 -23
  31. package/src/settings.js +24 -2
  32. package/src/store.js +155 -11
  33. package/src/summarize.js +104 -7
  34. package/test/api.test.js +7 -2
  35. package/test/baseline-persist.test.js +248 -0
  36. package/test/dream.test.js +75 -0
  37. package/test/graph-anchoring.test.js +185 -0
  38. package/test/graph-inject-budget.test.js +104 -0
  39. package/test/graph-passive-confirm.test.js +92 -0
  40. package/test/graph-weight-evolution.test.js +105 -0
  41. package/test/scope-inject-soft.test.js +106 -0
  42. package/test/summarize.test.js +177 -0
  43. package/test/test-count-sync.test.js +60 -0
package/lib/summarize.js CHANGED
@@ -15,10 +15,17 @@ function parseSummaryJsonResult(raw) {
15
15
  const end = text.lastIndexOf("]");
16
16
  if (start === -1 || end === -1 || end <= start) return { ok: false, entries: [] };
17
17
  let arr;
18
+ let salvaged = false;
18
19
  try {
19
20
  arr = JSON.parse(text.slice(start, end + 1));
20
21
  } catch {
21
- return { ok: false, entries: [] };
22
+ // issue #339 / E8 实测:10-20% 的窗口输出了含记忆条目的完整对象,只因中段
23
+ // 一处语法错误被整窗 JSON.parse 拒收;而失败路径的生产行为是游标不推进 +
24
+ // 温度 0 重试同文同错 = 该窗口记忆静默丢失。这里做失败路径修复:括号配对
25
+ // 扫描截出完整的顶层对象逐个 parse,救活多少算多少;截出 0 个不视为「模型
26
+ // 显式说无内容」(那是 ok:true 专属于真实空数组的语义)。
27
+ arr = salvageArrayItems(text.slice(start, end + 1));
28
+ salvaged = true;
22
29
  }
23
30
  if (!Array.isArray(arr)) return { ok: false, entries: [] };
24
31
  const VALID = new Set(["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"]);
@@ -38,8 +45,92 @@ function parseSummaryJsonResult(raw) {
38
45
  importance: Number.isInteger(item.importance) ? Math.min(5, Math.max(1, item.importance)) : 3
39
46
  }));
40
47
  // 空数组表示模型明确判断本轮没有可沉淀内容;非空数组若全部无效,
41
- // 则不能消费窗口,否则无效输出会永久推进 seq 游标。
42
- return { ok: arr.length === 0 || entries.length > 0, entries };
48
+ // 则不能消费窗口,否则无效输出会永久推进 seq 游标。salvage 路径
49
+ // 没有「显式空数组」可言——ok 只看是否救回了条目。
50
+ return {
51
+ ok: salvaged ? entries.length > 0 : (arr.length === 0 || entries.length > 0),
52
+ entries,
53
+ salvaged
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Salvage scanner for malformed JSON arrays (issue #339): extracts every
59
+ * complete top-level {...} span, parsing each independently. Two passes,
60
+ * merged with de-duplication:
61
+ * - pass 1 is string-aware (handles valid objects whose string values
62
+ * legitimately contain braces);
63
+ * - pass 2 ignores string state entirely — E8 现场的坏对象带奇数个引号
64
+ * (stray quote),pass 1 的奇偶失配会把后续对象的收尾 } 吞进字符串里,
65
+ * 盲扫按括号深度截取反而能救回它们。坏对象两种扫法都 parse 失败,自然
66
+ * 被丢弃;两遍的去重靠序列化键。
67
+ */
68
+ function salvageArrayItems(chunk) {
69
+ const out = [];
70
+ const seen = new Set();
71
+ const push = (obj) => {
72
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return;
73
+ const key = JSON.stringify(obj);
74
+ if (!seen.has(key)) { seen.add(key); out.push(obj); }
75
+ };
76
+ for (const obj of scanBraceSpans(chunk, true)) push(obj);
77
+ for (const obj of scanBraceSpans(chunk, false)) push(obj);
78
+ return out;
79
+ }
80
+
81
+ function scanBraceSpans(chunk, respectStrings) {
82
+ // 修订(CodeRabbit review on #350):只接受「最外层数组的直接子对象」——
83
+ // 候选 { 必须满足 arrayDepth===1 && objDepth===0 且前一非空白 token 是 [ 或
84
+ // ,。否则 (a) 嵌套子数组里的对象会被误捞成顶层记忆;(b) 盲扫遇到字符串值
85
+ // 里的 } 会提前断 span,把属性值对象当独立记忆落库。误拦的代价只是少救回
86
+ // (安全侧),误捞的代价是写进假记忆。
87
+ const items = [];
88
+ let arrayDepth = 0;
89
+ let objDepth = 0;
90
+ let inString = false;
91
+ let escape = false;
92
+ let objStart = -1;
93
+ let prev = "";
94
+ for (let i = 0; i < chunk.length; i++) {
95
+ const ch = chunk[i];
96
+ if (respectStrings && inString) {
97
+ if (escape) escape = false;
98
+ else if (ch === "\\") escape = true;
99
+ else if (ch === '"') inString = false;
100
+ continue;
101
+ }
102
+ if (respectStrings && ch === '"') { inString = true; continue; }
103
+ if (ch === "[") {
104
+ arrayDepth++;
105
+ prev = "[";
106
+ continue;
107
+ }
108
+ if (ch === "]") {
109
+ arrayDepth = Math.max(0, arrayDepth - 1);
110
+ prev = "]";
111
+ continue;
112
+ }
113
+ if (ch === "{") {
114
+ if (arrayDepth === 1 && objDepth === 0 && (prev === "[" || prev === ",")) {
115
+ objStart = i;
116
+ }
117
+ objDepth++;
118
+ prev = "{";
119
+ continue;
120
+ }
121
+ if (ch === "}") {
122
+ objDepth = Math.max(0, objDepth - 1);
123
+ if (objDepth === 0 && objStart !== -1) {
124
+ try { items.push(JSON.parse(chunk.slice(objStart, i + 1))); } catch { /* 坏对象跳过 */ }
125
+ objStart = -1;
126
+ }
127
+ prev = "}";
128
+ continue;
129
+ }
130
+ if (ch === ",") { prev = ","; continue; }
131
+ if (!/\s/.test(ch)) prev = ch;
132
+ }
133
+ return items;
43
134
  }
44
135
 
45
136
  /** Extract a JSON array from LLM output that may contain prose around it. */
@@ -195,9 +286,14 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
195
286
  break;
196
287
  }
197
288
  case "tool/result": {
198
- const result = Array.isArray(data?.message?.content)
199
- ? data.message.content.find((block) => block?.type === "tool-result")
200
- : undefined;
289
+ // 工具结果有两种形状:旧形状把载荷与 isError 装在 content 内的
290
+ // `type:"tool-result"` 块上;DSH 0.2.x 起内核改为直接投递 `role:"tool"`
291
+ // 消息本身,content 里只有 `type:"text"` 块、isError 挂在消息上。只认
292
+ // 旧块形状时新形状落进 undefined 分支,工具输出与工具报错被静默丢弃。
293
+ const message = data?.message;
294
+ const blocks = Array.isArray(message?.content) ? message.content : [];
295
+ const legacy = blocks.find((block) => block?.type === "tool-result");
296
+ const result = legacy ?? (message?.role === "tool" ? message : undefined);
201
297
  const out = textOf(result?.content);
202
298
  const status = result?.isError === true ? STR.statusFail[language] : STR.statusOk[language];
203
299
  lines.push(STR.transcriptToolResult[language](status, trim(out, 500)));
@@ -678,9 +774,10 @@ export function createSummarizer(ctx, service, config, deps = {}) {
678
774
  // 空数组是合法成功:没有记忆写入,但本次事件窗口仍然应被持久消费。
679
775
  persistCursor(session.id, nextSeq);
680
776
  }
681
- if (audit && (capped > 0 || deduped > 0)) {
777
+ if (audit && (capped > 0 || deduped > 0 || parsedResult.salvaged)) {
682
778
  audit.metadata = {
683
779
  parsed: parsed.length,
780
+ ...(parsedResult.salvaged ? { json_salvaged: true } : {}),
684
781
  ...(capped > 0 ? { capped } : {}),
685
782
  ...(deduped > 0 ? { deduped, mode: dedupeMode, maxSim: Number(dedupeMaxSim.toFixed(4)) } : {})
686
783
  };
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
- {
2
- "meta": {
3
- "title": "mneme",
4
- "description": "Cross-session memory: auto-distill each session into a local SQLite store, inject on demand, consolidate while idle (autoDream)."
5
- }
6
- }
1
+ {
2
+ "meta": {
3
+ "title": "mneme",
4
+ "description": "Cross-session memory: auto-distill each session into a local SQLite store, inject on demand, consolidate while idle (autoDream)."
5
+ }
6
+ }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
- {
2
- "meta": {
3
- "title": "mneme 记忆库",
4
- "description": "跨会话记忆:自动把会话沉淀进本机 SQLite,需要时按需注入,空闲时巩固(做梦)。"
5
- }
6
- }
1
+ {
2
+ "meta": {
3
+ "title": "mneme 记忆库",
4
+ "description": "跨会话记忆:自动把会话沉淀进本机 SQLite,需要时按需注入,空闲时巩固(做梦)。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
3
  "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 9 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.8.11",
4
+ "version": "0.8.12",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -71,10 +71,10 @@
71
71
  },
72
72
  "devDependencies": {
73
73
  "@deepseek-ai/cordis": "^4.0.1",
74
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
75
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
76
- "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
77
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
74
+ "@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.1",
75
+ "@deepseek-ai/dsh-llm": "^0.2.0-rc.1",
76
+ "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.1",
77
+ "@deepseek-ai/dsh-tools": "^0.2.0-rc.1",
78
78
  "@deepseek-ai/schemastery": "^3.18.1",
79
79
  "@huggingface/transformers": "^4.3.0",
80
80
  "c8": "^12.0.0",
@@ -104,7 +104,8 @@
104
104
  "morphicons": "^1.7.1"
105
105
  },
106
106
  "overrides": {
107
- "adm-zip": "0.6.1"
107
+ "adm-zip": "0.6.1",
108
+ "brace-expansion": "5.0.12"
108
109
  },
109
110
  "allowScripts": {
110
111
  "onnxruntime-node@1.24.3": true,
@@ -6,7 +6,9 @@
6
6
  // 1. 跑一遍全量测试(npm test),取套件总数(`ℹ tests N` 汇总行);
7
7
  // 2. 把双 README 的 tests 徽章(img.shields.io/badge/tests-N%20passed)与
8
8
  // 开发命令注释(# N 个测试 / # 运行 N 个测试 / # N 个 node:test 测试 /
9
- // # N tests)刷成该数字;
9
+ // # N tests)刷成该数字——形状集合收在 scripts/test-count-sync.mjs,与发版
10
+ // 自动路径 scripts/release-prep.mjs 共用(两条路径各存一份曾导致 v0.8.11
11
+ // 徽章与注释不同数);
10
12
  // 3. 有变化就写回文件(由调用方决定是否 commit/push),无变化静默退出。
11
13
  //
12
14
  // 测试失败(fail>0 或进程非零退出)时以非零退出且不改任何文件——徽章永远
@@ -16,6 +18,7 @@ import { execSync } from "node:child_process";
16
18
  import { readFileSync, writeFileSync } from "node:fs";
17
19
  import { dirname, join } from "node:path";
18
20
  import { fileURLToPath } from "node:url";
21
+ import { applyTestCount } from "./test-count-sync.mjs";
19
22
 
20
23
  const scriptDir = dirname(fileURLToPath(import.meta.url));
21
24
  const repoRoot = join(scriptDir, "..", "..");
@@ -46,18 +49,17 @@ if (!count) {
46
49
  process.exit(1);
47
50
  }
48
51
 
49
- // 3) 刷双 README(徽章 + 两行开发命令注释)
52
+ // 3) 刷双 README(徽章 + 四种开发命令注释形状)
53
+ // 形状集合与 release-prep.mjs(发版自动路径)共用,收在 scripts/test-count-sync.mjs —
54
+ // 两条路径各自维护一份规则正是 v0.8.11 注释漂移的根因。
50
55
  const targets = [
51
56
  join(repoRoot, "README.md"),
52
57
  join(repoRoot, "dsh-mneme", "README.md")
53
58
  ];
54
59
  let changed = 0;
55
60
  for (const file of targets) {
56
- let text = readFileSync(file, "utf8");
57
- const next = text
58
- .replace(/tests-\d+%20passed-/g, `tests-${count}%20passed-`)
59
- .replace(/(#\s*(?:运行\s*)?)\d+( 个(?:\s*node:test)?\s*测试)/g, `$1${count}$2`)
60
- .replace(/(#\s*)\d+( tests)/g, `$1${count}$2`);
61
+ const text = readFileSync(file, "utf8");
62
+ const next = applyTestCount(text, count);
61
63
  if (next !== text) {
62
64
  writeFileSync(file, next);
63
65
  changed += 1;
@@ -0,0 +1,44 @@
1
+ // 测试数在双 README 里以多种形状重复出现:shields.io 徽章 URL,以及四种 `# N …测试`
2
+ // 注释(`# N 个测试` / `# 运行 N 个测试` / `# N 个 node:test 测试` / `# N tests`)。
3
+ // 所有出现处必须永远报同一个数。
4
+ //
5
+ // 这些规则原先在两条写入路径里各存一份,且形状集合不同:
6
+ // - scripts/release-prep.mjs(Release Prep 工作流,自动,每次发版跑)
7
+ // - dsh-mneme/scripts/sync-test-badge.mjs(npm run badge:sync,手动)
8
+ // release-prep 当时只 replace 徽章 URL,于是 v0.8.11 发版时徽章自动跳到 1437,
9
+ // 四条注释留在 1431 —— 自动路径反而成了漂移的来源。规则收在这里共用,
10
+ // 由 test/test-count-sync.test.js 锁住形状集合与「双 README 同数」这条不变量。
11
+ //
12
+ // 徽章正则刻意不带尾随 `-`:`tests-N%20passed` 既能命中 `…passed-3E63DD`(root)
13
+ // 也能命中 `…passed-success`(包 README),将来出现无后缀写法也不会静默漏改。
14
+
15
+ const BADGE = /tests-\d+%20passed/g;
16
+ const COMMENT_CJK = /(#\s*(?:运行\s*)?)\d+( 个(?:\s*node:test)?\s*测试)/g;
17
+ const COMMENT_EN = /(#\s*)\d+( tests)/g;
18
+
19
+ /** 把一段文本里所有测试数出现处刷成 count(含徽章与四种注释形状)。 */
20
+ export function applyTestCount(text, count) {
21
+ return text
22
+ .replace(BADGE, `tests-${count}%20passed`)
23
+ .replace(COMMENT_CJK, `$1${count}$2`)
24
+ .replace(COMMENT_EN, `$1${count}$2`);
25
+ }
26
+
27
+ /**
28
+ * 只读地抽出文本里所有测试数字面量及其形状,供「处处一致」断言使用。
29
+ * 历史版本表里的「745 测试全绿」不带 `#` 前缀,刻意不在两种口径内——那是各版本
30
+ * 当时的真实记录,不该被后续发版改写。
31
+ */
32
+ export function readTestCounts(text) {
33
+ const found = [];
34
+ for (const m of text.matchAll(/tests-(\d+)%20passed/g)) {
35
+ found.push({ shape: "badge", value: Number(m[1]) });
36
+ }
37
+ for (const m of text.matchAll(/#\s*(?:运行\s*)?(\d+)\s*个(?:\s*node:test)?\s*测试/g)) {
38
+ found.push({ shape: "comment-cjk", value: Number(m[1]) });
39
+ }
40
+ for (const m of text.matchAll(/#\s*(\d+)\s+tests/g)) {
41
+ found.push({ shape: "comment-en", value: Number(m[1]) });
42
+ }
43
+ return found;
44
+ }
package/src/config.js CHANGED
@@ -281,6 +281,14 @@ export const Config = z.object({
281
281
  // 显式开启后放宽跨类型合并检查(类型边界由用户自行承担);配合
282
282
  // dreamSkipInvalid 理解:关闭 skipInvalid 时跨类型 merge 直接整单拒绝。
283
283
  allowCrossTypeMerge: z.boolean().default(false),
284
+ // issue #339 / E8 考卷:merge 护栏(opt-in,默认关 = 现状)。开启后合并对象
285
+ // 命中长保留类型(preference/pattern/rejected_solution/constraint/pitfall,
286
+ // 与 archive 护栏同一张表)的 merge 决策整条跳过——E8 实测巩固损耗里
287
+ // 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉,而 archive 护栏只挡
288
+ // archive 不挡 merge,「更精炼的摘要」恰是约束失真的主通道。skipInvalid
289
+ // (默认开)时被跳条目进 dream_runs.skipped、run 记 degraded;关闭
290
+ // skipInvalid 时整单拒绝。与 allowCrossTypeMerge 同用时长 guard 先判。
291
+ dreamMergeGuard: z.boolean().default(false),
284
292
  // Rule version for dream adjudication: when this bumps, older dream_runs
285
293
  // degrade to historical evidence (their receipts no longer drive live
286
294
  // decisions). Default 0 = no versioning in use yet.
@@ -463,6 +471,35 @@ export const Config = z.object({
463
471
  // 检索融合池(与 BM25 同级的确认/回填信号)。默认关=检索行为与 #219 前
464
472
  // 逐字节一致;走 feature_flags 面板可启停,lightMode 强制关闭。
465
473
  entityRecallEnabled: z.boolean().default(false),
474
+ // 边权重演化(issue #24 块2):touch 门控是否把被寻回路径的边权重抬升。
475
+ // 默认关=边初值纯由建边来源决定(store.RELATION_SOURCE_DEFAULTS),演化
476
+ // 不生效;开=被激活的边经 bumpRelationWeight 上涨(只加不减、封顶 1.0)。
477
+ graphWeightEnabled: z.boolean().default(false),
478
+ // 单次有效触达的抬升幅度。
479
+ graphWeightDelta: z.number().min(0).max(1).default(0.1),
480
+ // 被动确认(issue #24 块4):heat 触达(记忆被召回/注入=被用)即对该记忆
481
+ // 挂联的关系边做被动确认——bumpRelationWeight 抬一格。默认关=零行为变化;
482
+ // 开=正常触达演化边权,「关系是用出来的」闭环落地(异常路径复核留后续块)。
483
+ graphPassiveConfirm: z.boolean().default(false),
484
+
485
+ // --- 图谱锚定层(issue #24 · 块1 Activation Anchoring)-------------------
486
+ // entityRecallEnabled 之上再开一层:以查询命中的实体为种子,沿实体关系表
487
+ // 级联扩散(默认深度 2),把「邻居实体挂联的记忆」也带进融合池。种子集
488
+ // 上限 graphSeedCap、级联深度 graphCascadeDepth 一并下发到 anchorSeeds /
489
+ // cascadeDepths(见 src/graph/anchoring.js)。默认关=行为与 #219 单跳轴
490
+ // 逐字节一致;走 feature_flags 面板可启停,lightMode 强制关闭。
491
+ graphAnchoringEnabled: z.boolean().default(false),
492
+ graphSeedCap: z.natural().min(1).max(30).default(12),
493
+ graphCascadeDepth: z.natural().min(1).max(3).default(2),
494
+
495
+ // 关联提示防幻觉(issue #24 块3):图召回的候选(查询命中实体的挂联记忆)
496
+ // 进注入时默认只参与排序、不改变注入块构成(graphInjectHint=false 保守档);
497
+ // 开启后注入文本里以固定句式「[检索线索] …」标出,引导语说明这是链路信息
498
+ // 非事实断言,防 LLM 单次消费把桥接召回当有依据的事实回头扩散。
499
+ graphInjectHint: z.boolean().default(false),
500
+ // 图召回候选在注入块里的独立预算(条数上限)。与 pin/document 预算同构:
501
+ // 不占 maxInjectedItems 名额、只约束「桥接线索」这类低信任信息的常驻占比。
502
+ graphInjectBudget: z.natural().min(0).max(5).default(1),
466
503
 
467
504
  // --- sleep mode: idle-triggered deep maintenance (v0.4.0) ---------------
468
505
  // Opt-in, off by default. Unlike autoDream (threshold-triggered, lightweight)
@@ -685,7 +722,8 @@ export const Config = z.object({
685
722
  // agent_scope, workspace_scope, sensitivity)。检索侧加权/过滤在 A2/A3 落地。
686
723
  // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。
687
724
  scopeEnabled: z.boolean().default(false),
688
- // strictScope(A3):硬过滤模式。关闭=A2 软隔离(他 scope 降权保留可见);
725
+ // strictScope(A3):硬过滤模式。关闭=A2 软隔离(他 scope 降权保留可见,
726
+ // 检索与注入排序同权重,issue #339 补齐注入通道);
689
727
  // 开启后检索/注入/list/get 按 issue #17 四象限可见性公式硬过滤——带他 scope
690
728
  // 的记忆完全不可见,未标注(NULL)恒可见;当前会话某维度解析不到时该维度
691
729
  // 带标注的记忆一律不可见(fail-closed:身份不明只见全局)。依赖 scopeEnabled
@@ -712,6 +750,14 @@ const LIGHT_MODE_OFF = [
712
750
  "bm25SearchEnabled",
713
751
  // 轻量模式不开图召回轴(#219,依赖实体抽取产出;抽取本身已被关掉)。
714
752
  "entityRecallEnabled",
753
+ // 轻量模式不开图谱锚定层级联(#24 块1,依赖实体抽取 + 关系表,抽取已关则无边可扩)。
754
+ "graphAnchoringEnabled",
755
+ // 轻量模式不开边权重演化(#24 块2,依赖实体抽取产出;抽取关则无 v08 边可抬升)。
756
+ "graphWeightEnabled",
757
+ // 轻量模式不开关联提示标注(#24 块3,常驻注入文本多一段提示成本)。
758
+ "graphInjectHint",
759
+ // 轻量模式不开被动确认(#24 块4,依赖实体抽取产出;抽取关则无 v08 边可抬升)。
760
+ "graphPassiveConfirm",
715
761
  // 轻量模式不开叙述条(额外 LLM 调用;#164 对齐,opt-in)。
716
762
  "dreamNarrativeEnabled",
717
763
  // 轻量模式不开 document 指针行(#230,opt-in:注册/注入/检索增强全随闸)。
@@ -210,6 +210,22 @@ export function validateDecisions(decisions, snapshot, options = {}) {
210
210
  if (mergeTypes.size > 1 && options.allowCrossTypeMerge !== true) {
211
211
  local.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
212
212
  }
213
+ // issue #339 / E8 考卷:merge 护栏(opt-in,dreamMergeGuard)。巩固损耗
214
+ // 实测里 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉——archive
215
+ // 护栏(上方)只挡 archive 不挡 merge,而 merge 的「更精炼摘要」恰是
216
+ // 约束失真的主通道。开启后被合并对象命中 ARCHIVE_GUARDED_TYPES 的
217
+ // merge 决策整条跳过(与 archive 护栏同款通道:skipInvalid 时 skipped
218
+ // 明细、严格模式整单拒绝)。
219
+ if (local.length === 0 && options.mergeGuard === true) {
220
+ const guardedTypes = new Set();
221
+ for (const id of d.ids) {
222
+ const mem = snapshot.get(id);
223
+ if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type);
224
+ }
225
+ if (guardedTypes.size > 0) {
226
+ local.push(`${at}: merge of long-retention type(s) ${[...guardedTypes].join(", ")} is blocked by dreamMergeGuard`);
227
+ }
228
+ }
213
229
  }
214
230
  }
215
231
  if (local.length > 0) {
@@ -373,6 +373,9 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
373
373
  minAgeHours: config.reflectionUpdateMinAgeHours,
374
374
  skipInvalid: config.dreamSkipInvalid !== false,
375
375
  allowCrossTypeMerge: config.allowCrossTypeMerge === true,
376
+ // issue #339 / E8:guarded 类型 merge 护栏(opt-in,dreamMergeGuard),
377
+ // 与 dream 主链路同一判据。
378
+ mergeGuard: config.dreamMergeGuard === true,
376
379
  // Issue #126 review(Copilot):默认档必须真的只放行旧动作集——只换 prompt 挡不住
377
380
  // 模型自发输出 supersede / differentiate,那样"opt-in 零行为变化"就不成立。
378
381
  allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS
package/src/dream.js CHANGED
@@ -52,6 +52,26 @@ function totalChars(memories) {
52
52
  return memories.reduce((sum, m) => sum + (m.title?.length ?? 0) + (m.content?.length ?? 0), 0);
53
53
  }
54
54
 
55
+ // 阈值口径的唯一实现:活跃记忆(未归档、非 summary/document)的条数与正文字符数。
56
+ // 触发判定(shouldTrigger)与「成功轮把基线落库」(runDream 的 finish)都走这里——
57
+ // 两处若各写一份过滤条件,重启播种读到的基线就会和运行期判定口径漂移(一个把
58
+ // document 算进去、一个不算),阈值随之失真,且这种漂移只会在重启后才显形。
59
+ function activeStoreSize(service) {
60
+ const memories = service.all().filter((m) => !m.archived && m.type !== "summary" && m.type !== "document");
61
+ return { count: memories.length, chars: totalChars(memories) };
62
+ }
63
+
64
+ // 基线的唯一合法形状:两个非负整数;其余(缺字段、负数、非整数、整个对象缺失)
65
+ // 一律视为「没有基线」→ 调用方退回 {0,0}。播种值来自持久化列,可能是老库的 NULL
66
+ // 或手工改坏的库,判定必须比调用点更严格,而不是相信数据库里存的一定合法。
67
+ function toBaseline(raw) {
68
+ if (!raw || typeof raw !== "object") return null;
69
+ const count = Number.isInteger(raw.count) && raw.count >= 0 ? raw.count : null;
70
+ const chars = Number.isInteger(raw.chars) && raw.chars >= 0 ? raw.chars : null;
71
+ if (count === null || chars === null) return null;
72
+ return { count, chars };
73
+ }
74
+
55
75
  // ---------------------------------------------------------------- audit
56
76
 
57
77
  /**
@@ -760,7 +780,7 @@ export async function maintainIndexAfterDream(decisions, service, semantic) {
760
780
  // 用户配得比这更大的 dreamMinIntervalMinutes 基数压小(见 effectiveMinIntervalMs)。
761
781
  const FAILURE_BACKOFF_CAP_MS = 30 * 60 * 1000;
762
782
 
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 }) {
783
+ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChars = 5000, delayMs = 2000, minIntervalMs = 0, failureBackoff = false, logger, semantic = null, lastRunAtSeed = 0, baselineSeed = null, peakHours = "", peakMaxDeferMinutes = 120, auditPeakSkip = null, now = () => Date.now(), setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout }) {
764
784
  let pendingTimer = null;
765
785
  // Issue #239(第 4 项)镜像到巩固:高峰顺延定时器。与 pendingTimer 分开——两者
766
786
  // 语义不同(一个是「马上要跑」,一个是「等出高峰再跑」),合成一个变量会让
@@ -768,7 +788,13 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
768
788
  let deferTimer = null;
769
789
  let running = false;
770
790
  let disposed = false;
771
- let baseline = { count: 0, chars: 0 };
791
+ // Issue #89(基线半边):阈值基线跨重启持久化。baselineSeed 由调用方从
792
+ // dream_runs 读上次成功轮结束时的库规模(store.lastDreamBaseline)——它只活在
793
+ // 闭包内存里时进程重启即归零,而库里记忆远多于阈值,重启后第一条写入就满足
794
+ // overBase、绕过阈值开跑整轮(#291 只补了 lastRunAt 那半边)。缺种子(新库、
795
+ // 升级后还没跑过成功轮)或种子非法时退回 {0,0}:不假装知道基线,行为与升级前
796
+ // 逐字节一致——修的是「知道却不认」,不是凭空造一个基线。
797
+ let baseline = toBaseline(baselineSeed) ?? { count: 0, chars: 0 };
772
798
  let inFlight = null;
773
799
  // Issue #89(请求 2):上一次实际开跑时刻。失败/degraded 的 run 也占用
774
800
  // 最小间隔——节流的目的正是防止失败调用连发;间隔内的触发静默跳过。
@@ -792,9 +818,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
792
818
  }
793
819
 
794
820
  function shouldTrigger(service) {
795
- const memories = service.all().filter((m) => !m.archived && m.type !== "summary" && m.type !== "document");
796
- const count = memories.length;
797
- const chars = totalChars(memories);
821
+ const { count, chars } = activeStoreSize(service);
798
822
  const overBase = count >= baseline.count + thresholdCount || chars >= baseline.chars + thresholdChars;
799
823
  const overAbs = count >= thresholdCount || chars >= thresholdChars;
800
824
  return { trigger: overAbs && overBase, count, chars };
@@ -881,7 +905,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
881
905
  if (result && result.ok) {
882
906
  consecutiveFailures = 0; // Issue #292:成功清零,下次触发回到基数间隔
883
907
  try {
884
- baseline = shouldTrigger(service);
908
+ // Issue #89(基线半边):run 自报的基线(与审计行同一份数字)优先——重启
909
+ // 播种读的就是它,两条路径共用唯一口径;no-op 桩不回这个字段时退回现场
910
+ // 计算,最小测试替身的行为逐字节不变。
911
+ baseline = toBaseline(result.baseline) ?? shouldTrigger(service);
885
912
  } catch (error) {
886
913
  // Store closed mid-flight: keep the last known baseline.
887
914
  logger?.warn?.(`dsh-mneme dream: baseline refresh failed: ${String(error)}`);
@@ -986,6 +1013,19 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
986
1013
  const applied = result.applied ?? 0;
987
1014
  const summaryStored = result.summary ?? false;
988
1015
  const receipt = buildReceipt({ runId, status, snapshotHash, inputCount: snapshot.size, applied, summaryStored });
1016
+ // Issue #89(基线半边):成功轮把「本轮结束后的库规模」落进审计行 = 阈值基线的
1017
+ // 持久化副本,重启后由 store.lastDreamBaseline 播种。判定条件必须与调度器刷新
1018
+ // 基线的条件严格一致(result.ok)——非 ok 轮不推进基线是既有语义,若这里也落值,
1019
+ // 重启后播种就会拿到一个「其实没推进过」的假基线,阈值反而被抬高。
1020
+ // 取不到(库已关闭等)就落 NULL:宁可不持久化,也不写一个错的基线。
1021
+ let baselineSnapshot = null;
1022
+ if (result.ok) {
1023
+ try {
1024
+ baselineSnapshot = activeStoreSize(service);
1025
+ } catch {
1026
+ baselineSnapshot = null;
1027
+ }
1028
+ }
989
1029
  try {
990
1030
  service.saveDreamRun({
991
1031
  id: runId,
@@ -1023,12 +1063,18 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1023
1063
  // 而非内联进 decisions——degraded 的 decisions 是合法子集,下游按「决策
1024
1064
  // 数组」消费,内联标记会污染其它读者(失败轮的 _validationFailed 是整单
1025
1065
  // 拒绝哨兵,语义与「部分应用」不同,也不复用)。
1026
- skipped: result.skipped
1066
+ skipped: result.skipped,
1067
+ // Issue #89(基线半边):成功的 run 才带这两个值(见上方 baselineSnapshot
1068
+ // 的注释)。两列要么同时有值、要么同时为 NULL,读侧以「同时有值」认账。
1069
+ store_count: baselineSnapshot?.count,
1070
+ store_chars: baselineSnapshot?.chars
1027
1071
  });
1028
1072
  } catch (error) {
1029
1073
  logger?.warn?.(`dsh-mneme dream: failed to record audit run: ${String(error)}`);
1030
1074
  }
1031
- return { ...result, runId, receipt, snapshotHash };
1075
+ // Issue #89(基线半边):把「与审计行同一份数字」的基线回给调度器,两条路径
1076
+ // (进程内刷新 / 跨重启播种)因此共享唯一口径;调度器拿不到时会退出现场计算。
1077
+ return { ...result, runId, receipt, snapshotHash, baseline: baselineSnapshot ?? undefined };
1032
1078
  };
1033
1079
  if (!route) {
1034
1080
  logger?.warn?.("dsh-mneme dream: no llm route available");
@@ -1135,7 +1181,9 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1135
1181
  // Issue #89:v0.6.9(Issue #26)的宽容路径在 v0.7.11 重写中丢失——单条
1136
1182
  // 非法决策重新只跳过该条、合法子集照常应用(run 记为 degraded)。
1137
1183
  skipInvalid: config.dreamSkipInvalid !== false,
1138
- allowCrossTypeMerge: config.allowCrossTypeMerge === true
1184
+ allowCrossTypeMerge: config.allowCrossTypeMerge === true,
1185
+ // issue #339 / E8:guarded 类型 merge 护栏(opt-in,dreamMergeGuard)。
1186
+ mergeGuard: config.dreamMergeGuard === true
1139
1187
  });
1140
1188
  // Issue #135:模型把 UUID 缩写成前缀时,唯一前缀已在校验前被解析回完整 id。
1141
1189
  // 解析量是模型输出质量的一个直接信号(>0 意味着模型在缩写 id),记一条 info
@@ -226,7 +226,10 @@ export async function extractEntities(memory, { store, config, callLLM, logger }
226
226
  to_entity: toId,
227
227
  relation_type: rel.type,
228
228
  memory_id: memory.id,
229
- metadata: { model: options.model || "default" }
229
+ metadata: { model: options.model || "default" },
230
+ // issue #24 块2:LLM 抽取自动建边 → llm 来源(初值 0.4),区别于
231
+ // 人工/确认的满权边。来源进 metadata 之上的独立列,演化与审计可辨。
232
+ source: "llm"
230
233
  });
231
234
  savedRelations.push(saved);
232
235
  } catch (err) {