@modusensus/dsh-mneme 0.8.10 → 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 (45) hide show
  1. package/README.md +5 -5
  2. package/assets/icon.svg +3 -0
  3. package/lib/client.js +190 -58
  4. package/lib/config.js +47 -1
  5. package/lib/dream/decisions.js +16 -0
  6. package/lib/dream/sleep.js +3 -0
  7. package/lib/dream.js +57 -9
  8. package/lib/entities/extractor.js +4 -1
  9. package/lib/graph/anchoring.js +180 -0
  10. package/lib/index.js +5 -0
  11. package/lib/inject.js +15 -3
  12. package/lib/lang.js +10 -0
  13. package/lib/service.js +164 -23
  14. package/lib/settings.js +24 -2
  15. package/lib/store.js +155 -11
  16. package/lib/summarize.js +104 -7
  17. package/locale/en.json +6 -0
  18. package/locale/zh.json +6 -0
  19. package/package.json +12 -7
  20. package/scripts/sync-test-badge.mjs +9 -7
  21. package/scripts/test-count-sync.mjs +44 -0
  22. package/src/config.js +47 -1
  23. package/src/dream/decisions.js +16 -0
  24. package/src/dream/sleep.js +3 -0
  25. package/src/dream.js +57 -9
  26. package/src/entities/extractor.js +4 -1
  27. package/src/graph/anchoring.js +180 -0
  28. package/src/index.js +5 -0
  29. package/src/inject.js +15 -3
  30. package/src/lang.js +10 -0
  31. package/src/service.js +164 -23
  32. package/src/settings.js +24 -2
  33. package/src/store.js +155 -11
  34. package/src/summarize.js +104 -7
  35. package/test/api.test.js +7 -2
  36. package/test/baseline-persist.test.js +248 -0
  37. package/test/client.test.js +245 -45
  38. package/test/dream.test.js +75 -0
  39. package/test/graph-anchoring.test.js +185 -0
  40. package/test/graph-inject-budget.test.js +104 -0
  41. package/test/graph-passive-confirm.test.js +92 -0
  42. package/test/graph-weight-evolution.test.js +105 -0
  43. package/test/scope-inject-soft.test.js +106 -0
  44. package/test/summarize.test.js +177 -0
  45. package/test/test-count-sync.test.js +60 -0
package/lib/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) {
@@ -0,0 +1,180 @@
1
+ // dsh-mneme/src/graph/anchoring.js
2
+ // 图谱锚定层(issue #24 · 块1 Activation Anchoring)。
3
+ import assert from "node:assert/strict";
4
+ // 纯函数模块:把「查询命中实体的多路激活信号」归一化、合并成种子集、再沿
5
+ // entity_relations 级联扩散。零数据库依赖——store 侧查询由调用方注入,
6
+ // 本模块只做信号处理,便于单测与与其他召回通路(BM25/向量)同等对待。
7
+ //
8
+ // 设计输入来自 #24 合议 + heptaspirit serendipity-engine 实战(#24 评论):
9
+ // - 归一化二分法:连续分(cos/PPR)走 min-max;匹配通路(精确/tag/别名)
10
+ // 走离散级别,不用 min-max——布尔通路用 min-max 会把「中 1 个」和「中
11
+ // 20 个」都拉平到 1.0,级别信息被压平。
12
+ // - 级联扩散加「跳数配额」:λ/θ 只管可及性,可见性由配额决定——直接按 hop
13
+ // 深度打 0.5/0.3/0.2 权重,配桶内 round-robin,防深跳被 1-hop 完全压住。
14
+ // - graphSeedCap:防「多锚点命中 + 模糊通路」把种子集撑爆成整库。
15
+ // - 排序稳定:并列按实体 ID 破序(同输入两次结果不同会让 A/B 调参白做)。
16
+
17
+ /** 匹配通路的离散级别(heptaspirit 建议:布尔/计数通路不用 min-max)。 */
18
+ export const DISCRETE_LEVELS = Object.freeze({
19
+ exact: 1.0, // 名称精确含于查询(布尔 1.0)
20
+ title: 0.8, // 实体名匹配记忆标题(tag/别名层)
21
+ alias: 0.6,
22
+ tag: 0.4, // tag 共现
23
+ substring: 0.2, // 子串命中(最弱,纯计数不级联)
24
+ });
25
+
26
+ /** 级联每跳的权重(跳数配额:1:2:3-hop = 0.5/0.3/0.2)。 */
27
+ export const HOP_QUOTA = Object.freeze({ 1: 0.5, 2: 0.3, 3: 0.2 });
28
+
29
+ /**
30
+ * 连续分 min-max 归一化(向量 cos 相似度、PPR 激活分等)。
31
+ * 输入 [] 或全同值时返回原样(无尺度可压平,防除零)。
32
+ */
33
+ export function minMax(scores) {
34
+ const arr = Array.isArray(scores) ? scores : [];
35
+ if (arr.length === 0) return [];
36
+ let min = Infinity;
37
+ let max = -Infinity;
38
+ for (const v of arr) {
39
+ if (v < min) min = v;
40
+ if (v > max) max = v;
41
+ }
42
+ if (max - min < 1e-12) return arr.map(() => 1); // 平局视为满权
43
+ return arr.map((v) => (v - min) / (max - min));
44
+ }
45
+
46
+ /**
47
+ * 把一条检测通路的结果归一化成 0..1 的打分表。
48
+ * path 是离散通路名(exact/tag/...)→ 查 DISCRETE_LEVELS;是连续通路
49
+ * (continuous=true)→ min-max。返回 { byId: Map<id, score>, order: [id...] }。
50
+ */
51
+ export function normalizePath(hits, { path = "exact", continuous = false } = {}) {
52
+ const ids = Array.isArray(hits) ? hits : [];
53
+ const byId = new Map();
54
+ if (continuous) {
55
+ const raw = ids.map((h) => (typeof h === "number" ? h : h?.score ?? 0));
56
+ const norm = minMax(raw);
57
+ ids.forEach((h, i) => {
58
+ const id = typeof h === "object" ? h.id : h;
59
+ byId.set(id, norm[i]);
60
+ });
61
+ } else {
62
+ const level = DISCRETE_LEVELS[path] ?? DISCRETE_LEVELS.substring;
63
+ for (const id of ids) byId.set(id, level);
64
+ }
65
+ return { byId, order: [...byId.keys()] };
66
+ }
67
+
68
+ /**
69
+ * 合并多路激活成种子集:每路先归一化(调用方负责通路分类),再累加。
70
+ * seedsCap 上限(graphSeedCap,默认 12);封顶前按总分排序取前 N——保证
71
+ * 扩散的是活跃子图而非冷启动点爆整库。
72
+ * 返回 { id, name, score, paths: Set } 列表,按 score 降序、name 升序(稳定)。
73
+ */
74
+ export function anchorSeeds({ paths = [], cap = 12 } = {}) {
75
+ const score = new Map();
76
+ const nameOf = new Map();
77
+ const pathSet = new Map();
78
+ for (const p of paths) {
79
+ for (const [id, s] of p.byId ?? []) {
80
+ score.set(id, (score.get(id) ?? 0) + s);
81
+ if (!pathSet.has(id)) pathSet.set(id, new Set());
82
+ pathSet.get(id).add(p.path ?? "?");
83
+ if (p.nameOf?.has?.(id)) nameOf.set(id, p.nameOf.get(id));
84
+ }
85
+ }
86
+ let seeds = [...score.entries()].map(([id, s]) => ({
87
+ id,
88
+ name: nameOf.get(id) ?? id,
89
+ score: s,
90
+ paths: pathSet.get(id) ?? new Set()
91
+ }));
92
+ seeds.sort((a, b) => b.score - a.score || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
93
+ if (cap > 0 && seeds.length > cap) seeds = seeds.slice(0, cap);
94
+ return seeds;
95
+ }
96
+
97
+ /**
98
+ * 级联扩散:从种子实体出发沿 adjacency 走 BFS,为每个可达实体贴最短深度。
99
+ * depth 上界默认 2([1,3] 配置范围)。返回 Map<entityId, minDepth>——
100
+ * 调用方据此取 HOP_QUOTA[minDepth] 权重 + 桶内 round-robin(见 pickByHopQuota)。
101
+ * adjacency 由调用方提供(store.getEntityNeighbors(id) 或全量邻接矩阵),
102
+ * 本模块不感知存储。
103
+ */
104
+ export function cascadeDepths({ seeds, adjacencyOf, maxDepth = 2 } = {}) {
105
+ const depths = new Map();
106
+ const idOfSeeds = (seeds ?? []).map((s) => (typeof s === "object" ? s.id : s));
107
+ const queue = [];
108
+ for (const id of idOfSeeds) {
109
+ if (!depths.has(id)) {
110
+ depths.set(id, 0);
111
+ queue.push([id, 0]);
112
+ }
113
+ }
114
+ for (let i = 0; i < queue.length; i++) {
115
+ const [id, d] = queue[i];
116
+ if (d >= maxDepth) continue;
117
+ let neighbors = [];
118
+ try {
119
+ neighbors = typeof adjacencyOf === "function" ? adjacencyOf(id) : (adjacencyOf?.get?.(id) ?? []);
120
+ } catch { /* single neighbor fetch failure: keep spreading from the rest */ }
121
+ for (const nb of neighbors) {
122
+ const nid = typeof nb === "object" ? nb.id : nb;
123
+ if (!nid || depths.has(nid)) continue;
124
+ depths.set(nid, d + 1);
125
+ queue.push([nid, d + 1]);
126
+ }
127
+ }
128
+ return depths;
129
+ }
130
+
131
+ /**
132
+ * 跳数配额选取:候选实体按深度分层,层内按(分,ID)稳定排序后取额度。
133
+ * quota 形如 { 1: 0.5, 2: 0.3, 3: 0.2 }——每层最多取 ceil(depthCap * quota[d]) 个,
134
+ * 桶内按 score 降序(并列按 ID)。返回 { ids, scoreOf, depthOf }。
135
+ * 这一步把「级联可及性」与「最终可见性」解耦(heptaspirit:λ/θ 只管前者)。
136
+ */
137
+ export function pickByHopQuota({
138
+ depths = new Map(),
139
+ scoreOf = () => 0,
140
+ maxDepth = 2,
141
+ depthCap = 12
142
+ } = {}) {
143
+ const buckets = new Map(); // depth -> [{id, score}]
144
+ for (const [id, d] of depths) {
145
+ if (d === 0 || d > maxDepth) continue;
146
+ const key = Math.min(d, 3);
147
+ if (!buckets.has(key)) buckets.set(key, []);
148
+ buckets.get(key).push({ id, score: scoreOf(id) ?? 0 });
149
+ }
150
+ const picked = [];
151
+ for (const [d, items] of buckets) {
152
+ items.sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
153
+ const quota = HOP_QUOTA[d] ?? 0.2;
154
+ const n = Math.max(1, Math.floor(depthCap * quota));
155
+ picked.push(...items.slice(0, n).map((x) => x.id));
156
+ }
157
+ // 队内稳定序:按深度升序(1-hop 优先),保证可感知的扩散次序。
158
+ picked.sort((a, b) => (depths.get(a) - depths.get(b)) || (a < b ? -1 : a > b ? 1 : 0));
159
+ return { ids: picked, scoreOf, depthOf: depths };
160
+ }
161
+
162
+ // Lightweight self-check (ponytail: 非平凡逻辑留一个可运行断言)。
163
+ export function _selfCheck() {
164
+ const exact = normalizePath(["e1", "e2"], { path: "exact" });
165
+ const tag = normalizePath(["e2", "e3"], { path: "tag" });
166
+ assert(exact.byId.get("e1") === 1.0 && exact.byId.get("e2") === 1.0, "exact level");
167
+ assert(tag.byId.get("e2") === 0.4 && tag.byId.get("e3") === 0.4, "tag level");
168
+ const seeds = anchorSeeds({ paths: [exact, tag], cap: 10 });
169
+ assert(seeds[0].id === "e2", "merged highest first"); // e2: 精确+tag = 1.4
170
+ assert(seeds[0].score >= 1.4 - 1e-9, "score accumulates");
171
+ const adj = new Map([
172
+ ["e2", ["n1", "n2"]],
173
+ ["n1", ["n2", "deep3"]],
174
+ ]);
175
+ const depths = cascadeDepths({ seeds, adjacencyOf: (id) => adj.get(id) ?? [], maxDepth: 2 });
176
+ assert(depths.get("n1") === 1 && depths.get("n2") === 1 && depths.get("deep3") === 2, "bfs depth");
177
+ const { ids } = pickByHopQuota({ depths, maxDepth: 2, depthCap: 12 });
178
+ assert(ids.includes("n1") && ids.includes("n2") && ids.includes("deep3"), "quota picks all");
179
+ return "anchoring self-check ok";
180
+ }
package/lib/index.js CHANGED
@@ -502,6 +502,11 @@ export const apply = (ctx, config) => {
502
502
  logger: ctx.logger,
503
503
  semantic: { embedder, vectorIndex },
504
504
  lastRunAtSeed: store.lastDreamRunAt("auto"),
505
+ // Issue #89(基线半边):阈值基线同样跨重启恢复——只恢复 lastRunAt 的话,
506
+ // 重启后的第一条写入仍会绕过阈值(库里记忆多于阈值时 overBase 恒成立)开跑
507
+ // 整轮。种子取上次成功轮的库规模,口径与运行期刷新一致;读不到时调度器退回
508
+ // 零基线(= 升级前行为)。
509
+ baselineSeed: store.lastDreamBaseline("auto"),
505
510
  // Issue #239(第 4 项)镜像到巩固:高峰期不做梦,顺延到最近的高峰结束时刻。
506
511
  peakHours: cfg.dreamPeakHours ?? "",
507
512
  peakMaxDeferMinutes: cfg.dreamPeakMaxDeferMinutes ?? 120,
package/lib/inject.js CHANGED
@@ -274,9 +274,14 @@ export function createInjector(ctx, service, settings, config) {
274
274
  // 日志抢同一份预算后被降级」正是本议题要修的结构缺陷。单条仍有硬顶
275
275
  // (PINNED_CONTENT_MAX),超出照旧带截断提示,不静默。
276
276
  const pinnedCount = Math.max(0, Math.min(candidates.length, pinnedStats?.shown ?? 0));
277
+ // #24 块3:图召回线索行(graphHint)——引导语插在第一个线索行之前(保持块头
278
+ // 语义:先声明「这些不是事实」)。线索行自身按 graphHintLine 渲染(线索/ 前缀
279
+ // 区别于普通记忆行)。
280
+ let graphSeen = 0;
277
281
  for (let i = 0; i < candidates.length; i++) {
278
282
  const m = candidates[i];
279
283
  const pinned = i < pinnedCount;
284
+ if (m.graphHint === true) graphSeen++;
280
285
  // Epistemic trust (v0.4.5): when enabled, measured observations are
281
286
  // flagged so the agent can weigh them above guesses/opinions.
282
287
  const verified = config.trustEpistemicWeighting === true && m.epistemic_status === "observation"
@@ -284,7 +289,13 @@ export function createInjector(ctx, service, settings, config) {
284
289
  : "";
285
290
  const title = STR.entryTitle[language](m.title, m.importance);
286
291
  const content = injectMemory(m, pinned ? PINNED_CONTENT_MAX : maxContent);
287
- const full = STR.entryLine[language](m.type, verified, title, content);
292
+ const full = m.graphHint === true
293
+ ? STR.graphHintLine[language](m.type, title, content)
294
+ : STR.entryLine[language](m.type, verified, title, content);
295
+ // 线索行引导语插在第一个线索行之前(保持块头语义:先声明「这些不是事实」)。
296
+ if (m.graphHint === true && graphSeen === 1) {
297
+ lines.push(STR.graphHintHeader[language]);
298
+ }
288
299
  if (pinned) {
289
300
  // pin 不扣块预算(上面那条设计注释的落地):pin 一条就够击穿 MAX_BLOCK
290
301
  // (PINNED_CONTENT_MAX 2000 > MAX_BLOCK 1500),照扣会把 budget 压成负数,
@@ -363,8 +374,9 @@ export function createInjector(ctx, service, settings, config) {
363
374
  const query = lastUserQuery(ctx);
364
375
  if (query) prefetchQueryVector(query);
365
376
  const queryVector = queryVectorCache.get(query);
366
- // v0.8.0 A3:scope 随请求解析(strict 开启时 injectCandidates 内硬过滤;
367
- // flag 关闭时解析器返回 null,注入行为不变)。
377
+ // v0.8.0 A3:scope 随请求解析(解析器恒返回对象,身份取不到时两维为
378
+ // null);硬过滤与软加权的门控都在 injectCandidates 内——strict 看
379
+ // strictScope,软加权看 scopeEnabled 且至少一维可解析(issue #339)。
368
380
  const scope = resolveSessionScope(ctx);
369
381
  // Issue #205:跨轮轮换——最近 N 个查询轮次注入过的 id 本轮不再优先。
370
382
  const sessionId = ctx?.agent?.session?.id ?? "_";
package/lib/lang.js CHANGED
@@ -81,6 +81,16 @@ export const STR = {
81
81
  zh: (n) => `〔约束/偏好类另有 ${n} 条未展示〕`,
82
82
  en: (n) => `[${n} more constraint/preference entries not shown]`
83
83
  },
84
+ // Issue #24 块3:图召回线索行的引导语——正向声明「链路信息非事实断言」,
85
+ // 防 LLM 单次消费把桥接召回当有依据的事实回头扩散(幻觉诱导)。
86
+ graphHintHeader: {
87
+ zh: "[检索线索] 以下为实体图搜索的链路信息,仅供定位参考,非事实断言:",
88
+ en: "[Retrieval hints] Entity-graph link trails below are navigation aids, not factual assertions:"
89
+ },
90
+ graphHintLine: {
91
+ zh: (type, title, content) => `- [线索/${type}] ${title}:${content}`,
92
+ en: (type, title, content) => `- [hint/${type}] ${title}: ${content}`
93
+ },
84
94
  userSettingsHeader: {
85
95
  zh: "[用户设置] 来自 dsh-mneme 的用户画像与规则:",
86
96
  en: "[User settings] Profile and rules from dsh-mneme:"