dsh-prime-memory 0.26.5-beta.0 → 0.26.6

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 (66) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.en.md +62 -1
  3. package/README.md +52 -1
  4. package/dist/client.js +36785 -134
  5. package/dist/config.d.ts +9 -0
  6. package/dist/config.js +8 -0
  7. package/dist/contract.d.ts +76 -0
  8. package/dist/embedding-child.cjs +192 -0
  9. package/dist/embedding-worker.cjs +2 -0
  10. package/dist/graph/apply.d.ts +9 -0
  11. package/dist/graph/apply.js +12 -2
  12. package/dist/graph/constraints.d.ts +3 -1
  13. package/dist/graph/constraints.js +4 -2
  14. package/dist/graph/preview.d.ts +92 -0
  15. package/dist/graph/preview.js +186 -0
  16. package/dist/graph/search.d.ts +32 -6
  17. package/dist/graph/search.js +102 -12
  18. package/dist/graph/types.d.ts +26 -1
  19. package/dist/graph/types.js +26 -1
  20. package/dist/hooks/recall.js +36 -2
  21. package/dist/index.js +23 -3
  22. package/dist/llm.d.ts +7 -2
  23. package/dist/llm.js +14 -22
  24. package/dist/pipeline/graph.d.ts +26 -1
  25. package/dist/pipeline/graph.js +74 -4
  26. package/dist/pipeline/l1.js +87 -26
  27. package/dist/pipeline/md-sync.js +4 -2
  28. package/dist/pipeline/runner.d.ts +26 -0
  29. package/dist/pipeline/runner.js +74 -7
  30. package/dist/pipeline/slot-migrate.d.ts +3 -2
  31. package/dist/pipeline/slot-migrate.js +3 -2
  32. package/dist/projection/recall.d.ts +10 -3
  33. package/dist/projection/recall.js +14 -0
  34. package/dist/settings.js +3 -0
  35. package/dist/stats.d.ts +1 -1
  36. package/dist/stats.js +47 -12
  37. package/dist/store/embedding-source.d.ts +4 -2
  38. package/dist/store/embedding-source.js +5 -2
  39. package/dist/store/graph-store.d.ts +111 -4
  40. package/dist/store/graph-store.js +324 -15
  41. package/dist/store/l1.d.ts +17 -9
  42. package/dist/store/l1.js +55 -18
  43. package/dist/store/local-embedding.d.ts +9 -5
  44. package/dist/store/local-embedding.js +137 -17
  45. package/dist/store/memory-md-disable.d.ts +3 -2
  46. package/dist/store/memory-md.d.ts +3 -3
  47. package/dist/store/memory-md.js +6 -4
  48. package/dist/store/runtime-installer.d.ts +13 -1
  49. package/dist/store/runtime-installer.js +40 -2
  50. package/dist/store/session-recalled.d.ts +9 -3
  51. package/dist/store/session-recalled.js +24 -5
  52. package/dist/store/sqlite.d.ts +24 -12
  53. package/dist/store/sqlite.js +97 -31
  54. package/dist/store/supersede.d.ts +10 -2
  55. package/dist/store/supersede.js +10 -2
  56. package/dist/store/validity.d.ts +150 -0
  57. package/dist/store/validity.js +209 -0
  58. package/dist/tools/index.d.ts +15 -1
  59. package/dist/tools/index.js +393 -11
  60. package/dist/types.d.ts +9 -0
  61. package/dist/util/json-repair.d.ts +48 -0
  62. package/dist/util/json-repair.js +285 -0
  63. package/dist/validity-backfill.d.ts +78 -0
  64. package/dist/validity-backfill.js +160 -0
  65. package/dsh.plugin.json +2 -2
  66. package/package.json +5 -4
@@ -0,0 +1,186 @@
1
+ /**
2
+ * 面板图谱预览(读路径纯函数):种子推荐榜 + 指定节点邻域展开。
3
+ *
4
+ * 定位:图谱投影三表的"值得画哪些片"视图筛选,不查库、不落库、无 I/O——
5
+ * job 计数(pendingJobs/jobsTotal)由存储缝(graph-store.buildPreview)传入。
6
+ * 起点推荐有实证底座(2026-10-20 装机审计):关系全落规范词表、因果边 ~24%,
7
+ * 故 score 以 active 度数为主键、因果边加权——纯 top 度会漏"因→果"链。
8
+ *
9
+ * 判据与检索同源(search.ts):候选 = active|disputed 节点 × active 边;
10
+ * 确定性 = 同输入两次调用逐位一致(同分裁决链落到 id 升序兜底)。
11
+ */
12
+ import { GRAPH_CAUSAL_RELATIONS } from './constraints.js';
13
+ import { GRAPH_PREVIEW_CAUSAL_WEIGHT, GRAPH_PREVIEW_FULL_EDGES, GRAPH_PREVIEW_FULL_NODES, GRAPH_PREVIEW_NEIGHBORS, GRAPH_PREVIEW_SEEDS, } from './types.js';
14
+ const CAUSAL_SET = new Set(GRAPH_CAUSAL_RELATIONS);
15
+ /** 预览唯一判定的节点可见性(与 searchGraphNodes 候选集同式)。 */
16
+ function visible(n) {
17
+ return n.status === 'active' || n.status === 'disputed';
18
+ }
19
+ export function degreeMaps(nodes, edges) {
20
+ const candidate = new Set(nodes.filter(visible).map((n) => n.id));
21
+ const degree = new Map();
22
+ const causal = new Map();
23
+ for (const e of edges) {
24
+ if (e.status !== 'active')
25
+ continue;
26
+ if (!candidate.has(e.fromNodeId) || !candidate.has(e.toNodeId))
27
+ continue;
28
+ const isCausal = CAUSAL_SET.has(e.relation);
29
+ for (const id of [e.fromNodeId, e.toNodeId]) {
30
+ degree.set(id, (degree.get(id) ?? 0) + 1);
31
+ if (isCausal)
32
+ causal.set(id, (causal.get(id) ?? 0) + 1);
33
+ }
34
+ }
35
+ return { degree, causal };
36
+ }
37
+ /** 种子卡装配:邻居条目 + 因果优先排序 + 截断。 */
38
+ function seedView(center, edges, byId, dm, neighborLimit) {
39
+ const list = [];
40
+ for (const e of edges) {
41
+ if (e.status !== 'active')
42
+ continue;
43
+ let otherId;
44
+ if (e.fromNodeId === center.id)
45
+ otherId = e.toNodeId;
46
+ else if (e.toNodeId === center.id)
47
+ otherId = e.fromNodeId;
48
+ else
49
+ continue;
50
+ const o = byId.get(otherId);
51
+ if (!o || !visible(o))
52
+ continue;
53
+ list.push({
54
+ id: o.id,
55
+ name: o.name,
56
+ type: o.type,
57
+ relation: e.relation,
58
+ causal: CAUSAL_SET.has(e.relation),
59
+ _deg: dm.degree.get(o.id) ?? 0,
60
+ _upd: o.updatedAt,
61
+ });
62
+ }
63
+ list.sort((a, b) => {
64
+ if (a.causal !== b.causal)
65
+ return a.causal ? -1 : 1;
66
+ if (a._deg !== b._deg)
67
+ return b._deg - a._deg;
68
+ if (a._upd !== b._upd)
69
+ return a._upd < b._upd ? -1 : 1; // updatedAt 新(大)先
70
+ return a.id < b.id ? -1 : 1;
71
+ });
72
+ const truncated = list.length > neighborLimit;
73
+ return {
74
+ node: {
75
+ id: center.id,
76
+ name: center.name,
77
+ type: center.type,
78
+ degree: dm.degree.get(center.id) ?? 0,
79
+ causalDegree: dm.causal.get(center.id) ?? 0,
80
+ },
81
+ neighbors: list.slice(0, neighborLimit).map(({ _deg, _upd, ...view }) => view),
82
+ truncated,
83
+ };
84
+ }
85
+ /**
86
+ * S-3/S-4:预览主函数。
87
+ * - seedId 视图:直接展开该节点(跳过挑选);不可见/悬挂 → 空 seeds。
88
+ * - 榜单视图:score = degree + causal×WEIGHT;同分链 causal→sources→updatedAt→id↑;
89
+ * 候选落入已选种子的"自身∪邻居"覆盖集则跳过(相邻合并,画布片不重复);
90
+ * 孤立节点(度 0)不入榜——画布只有中心一个点没有信息量。
91
+ */
92
+ export function buildGraphPreview(nodes, edges, opts = {}) {
93
+ const byId = new Map(nodes.map((n) => [n.id, n]));
94
+ const dm = degreeMaps(nodes, edges);
95
+ const visNodes = nodes.filter(visible);
96
+ const visEdges = edges.filter((e) => e.status === 'active');
97
+ const stats = {
98
+ nodes: visNodes.length,
99
+ edges: visEdges.length,
100
+ causalEdges: visEdges.filter((e) => CAUSAL_SET.has(e.relation)).length,
101
+ pendingJobs: opts.pendingJobs ?? 0,
102
+ jobsTotal: opts.jobsTotal ?? 0,
103
+ ...(opts.deadJobs != null && opts.deadJobs > 0 ? { deadJobs: opts.deadJobs } : {}),
104
+ };
105
+ const seedLimit = Math.min(Math.max(Math.floor(opts.seedLimit ?? GRAPH_PREVIEW_SEEDS), 1), 20);
106
+ const neighborLimit = Math.min(Math.max(Math.floor(opts.neighborLimit ?? GRAPH_PREVIEW_NEIGHBORS), 1), 40);
107
+ if (opts.seedId !== undefined) {
108
+ const target = byId.get(opts.seedId);
109
+ if (!target || !visible(target))
110
+ return { seeds: [], stats };
111
+ return { seeds: [seedView(target, edges, byId, dm, neighborLimit)], stats };
112
+ }
113
+ const scored = visNodes
114
+ .map((n) => ({
115
+ n,
116
+ score: (dm.degree.get(n.id) ?? 0) + (dm.causal.get(n.id) ?? 0) * GRAPH_PREVIEW_CAUSAL_WEIGHT,
117
+ }))
118
+ .sort((a, b) => {
119
+ if (a.score !== b.score)
120
+ return b.score - a.score;
121
+ const ca = dm.causal.get(a.n.id) ?? 0;
122
+ const cb = dm.causal.get(b.n.id) ?? 0;
123
+ if (ca !== cb)
124
+ return cb - ca;
125
+ const sa = a.n.sourceRecordIds.length;
126
+ const sb = b.n.sourceRecordIds.length;
127
+ if (sa !== sb)
128
+ return sb - sa;
129
+ if (a.n.updatedAt !== b.n.updatedAt)
130
+ return a.n.updatedAt < b.n.updatedAt ? 1 : -1;
131
+ return a.n.id < b.n.id ? -1 : 1;
132
+ });
133
+ const picked = [];
134
+ const covered = new Set();
135
+ for (const { n, score } of scored) {
136
+ if (picked.length >= seedLimit)
137
+ break;
138
+ if (score === 0)
139
+ continue; // 孤立点不入榜(score 单调降,continue 等价于提前结束)
140
+ if (covered.has(n.id))
141
+ continue; // 相邻合并:已被前一种子的邻域覆盖
142
+ picked.push(n);
143
+ covered.add(n.id);
144
+ for (const e of visEdges) {
145
+ if (e.fromNodeId === n.id)
146
+ covered.add(e.toNodeId);
147
+ else if (e.toNodeId === n.id)
148
+ covered.add(e.fromNodeId);
149
+ }
150
+ }
151
+ return {
152
+ seeds: picked.map((n) => seedView(n, edges, byId, dm, neighborLimit)),
153
+ stats,
154
+ };
155
+ }
156
+ /**
157
+ * 全图星图数据块(纯函数,不查库):可见节点轻量投影 × active 边,
158
+ * 双上限截断。**顺序纪律**:节点按入参序截断(调用方 loadGraph 的行序),
159
+ * 悬挂边(端点之一被截掉/不可见)在截断前剔除——星图上不画悬空线。
160
+ */
161
+ export function buildFullView(nodes, edges, caps = {}) {
162
+ const nodeCap = Math.max(1, Math.floor(caps.nodeCap ?? GRAPH_PREVIEW_FULL_NODES));
163
+ const edgeCap = Math.max(1, Math.floor(caps.edgeCap ?? GRAPH_PREVIEW_FULL_EDGES));
164
+ const visibleNodes = nodes.filter(visible);
165
+ const nodesTruncated = visibleNodes.length > nodeCap;
166
+ const drawn = visibleNodes.slice(0, nodeCap);
167
+ const keep = new Set(drawn.map((n) => n.id));
168
+ const outEdges = [];
169
+ let edgesTruncated = false;
170
+ for (const e of edges) {
171
+ if (e.status !== 'active')
172
+ continue;
173
+ if (!keep.has(e.fromNodeId) || !keep.has(e.toNodeId))
174
+ continue;
175
+ if (outEdges.length >= edgeCap) {
176
+ edgesTruncated = true;
177
+ break;
178
+ }
179
+ outEdges.push({ from: e.fromNodeId, to: e.toNodeId, relation: e.relation, causal: CAUSAL_SET.has(e.relation) });
180
+ }
181
+ return {
182
+ nodes: drawn.map((n) => ({ id: n.id, name: n.name, type: n.type })),
183
+ edges: outEdges,
184
+ truncated: nodesTruncated || edgesTruncated,
185
+ };
186
+ }
@@ -9,23 +9,49 @@ export declare const GRAPH_FIELD_WEIGHTS: {
9
9
  readonly relations: 3;
10
10
  readonly type: 2;
11
11
  };
12
+ /**
13
+ * as-of 回放可选参数(expiry-tiering Phase 5:图谱读时派生,不加冻结列)。
14
+ * 默认(缺席)= 当前态检索,行为与改前逐字一致。
15
+ */
16
+ export interface GraphAsOfOptions {
17
+ /** ISO 8601 时刻:把"当时成立"的 superseded fact/边也纳入检索与回链;
18
+ * archived 仍排除(那是删除传播的墓碑,来源已不在,回放也不该复活)。 */
19
+ asOf?: string;
20
+ }
12
21
  /**
13
22
  * 图谱命中 → L1 记录 id 序列(§D 第 3 路 RRF 的输入;纯函数,无 I/O)。
14
23
  *
15
24
  * 图谱是**可重建派生投影**,节点/fact 的 `sourceRecordIds` 是它回链 L1 的唯一凭证
16
- * (types.ts:6-7)。此处把"节点排名"翻译成"L1 记录排名":
25
+ * (types.ts:6-7)。此处把"节点排名"翻译成"L1 记录排名",分两段:
26
+ *
27
+ * - **直连段**:命中节点自身 + 其 active facts 的来源(task_9 原语义、原顺序);
28
+ * - **1-hop 段**:命中节点沿 **active 边**的双向邻居(邻居需 active|disputed)的
29
+ * 节点自身来源,按邻居 updatedAt 新→旧追加,整段新增封顶
30
+ * `GRAPH_HOP_RECORD_CAP`——这是图路"关系可达"增量价值的兑现面。
31
+ * hop 段只取邻居节点自身来源,不取 fact 来源(hop 是联想不是证据,噪声从紧)。
17
32
  *
18
- * - **保序**:按命中的 score 降序依次展开来源,先出现的记录 = 更高排名;
19
- * - **去重**:同一 L1 记录可能被多个节点/fact 引用,只占首次出现的位置
20
- * (重复占位会凭空抬高该记录在 RRF 里的权重);
33
+ * 两段共用纪律:
34
+ * - **保序**:先出现的记录 = 更高排名(直连段恒先于 hop 段);已被直连段占位的
35
+ * id 不重复计入、也**不吃 hop 名额**(关联额度不被身份来源挤占);
36
+ * - **去重**:同一 L1 记录被多个节点/fact 引用,只占首次出现的位置;
21
37
  * - **节点自身来源优先于其 fact 来源**:节点的 sourceRecordIds 是它的身份来源,
22
38
  * 而单个 fact 的来源可能引向与该查询无关的旁支记录;
23
39
  * - **只取 active fact**:superseded 是历史状态,不该把旧记录拉进当前检索结果;
24
- * - **不查库**:返回的 id 可能已被删除(墓碑边界),由调用方按"取不到的跳过"处理。
40
+ * - **不查库**:返回的 id 可能已被删除(墓碑边界),由调用方按"取不到的跳过"处理,
41
+ * 不得补空占位(占位会在 RRF 里凭空加分)。
42
+ *
43
+ * edges/nodes 由调用方传入(loadGraph 结果,百~千量级全扫即可;>2k 再考虑预建索引,
44
+ * 见文件头演进阶梯)。族过滤不在本函数:邻居节点可跨族,兜底在 graphLane 尾部的
45
+ * 记录级 family/scope 双闸(宁可漏不可串落在记录上)。
46
+ */
47
+ export declare function expandHitRecordIds(hits: readonly GraphNodeSearchResult[], nodes: readonly GraphNode[], edges: readonly GraphEdge[], opts?: GraphAsOfOptions): string[];
48
+ /**
49
+ * 直连段回链(无 hop)——保留 task_9 的原语义形状(既有测试继续钉住);
50
+ * 生产链路一律走 expandHitRecordIds(向量种子并集 + 1-hop)。
25
51
  */
26
52
  export declare function graphHitRecordIds(hits: readonly GraphNodeSearchResult[]): string[];
27
53
  /**
28
54
  * 图谱节点检索:返回按 score 降序的命中(最多 limit 条;limit 钳制 1~20)。
29
55
  * 候选节点 = active | disputed(superseded/archived 不进检索)。
30
56
  */
31
- export declare function searchGraphNodes(nodes: readonly GraphNode[], edges: readonly GraphEdge[], query: string, limit: number): GraphNodeSearchResult[];
57
+ export declare function searchGraphNodes(nodes: readonly GraphNode[], edges: readonly GraphEdge[], query: string, limit: number, opts?: GraphAsOfOptions): GraphNodeSearchResult[];
@@ -34,7 +34,7 @@
34
34
  * - score 只用于排序,不是事实置信度;无词法命中返回空,不兜底。
35
35
  */
36
36
  import { tokenize } from '../util/text.js';
37
- import { GRAPH_SEARCH_LIMIT_MAX } from './types.js';
37
+ import { GRAPH_HOP_RECORD_CAP, GRAPH_SEARCH_LIMIT_MAX } from './types.js';
38
38
  /** 字段权重(检索命中计分;名称最高、关系词最低)。 */
39
39
  export const GRAPH_FIELD_WEIGHTS = {
40
40
  name: 6,
@@ -60,21 +60,54 @@ const FIELD_ORDER = ['name', 'aliases', 'tags', 'currentState', 'facts', 'relati
60
60
  function factValueText(value) {
61
61
  return Array.isArray(value) ? value.join(' ') : value;
62
62
  }
63
+ /** ISO 字符串区间覆盖判定(字典序=时间序;空串视作未闭合)。 */
64
+ function coversInterval(from, to, asOf) {
65
+ if (from && from > asOf)
66
+ return false;
67
+ if (to && asOf >= to)
68
+ return false;
69
+ return true;
70
+ }
71
+ /** fact 在回放时刻是否成立(active 恒成立;superseded 看区间)。archived/disputed 不含。 */
72
+ function factValidAtAsOf(f, asOf) {
73
+ if (f.status === 'active')
74
+ return true;
75
+ if (f.status === 'superseded')
76
+ return coversInterval(f.validFrom, f.validTo, asOf);
77
+ return false;
78
+ }
79
+ /** 节点新度序(updatedAt 新→旧,同值按 id 升序——与检索结果排序同式)。 */
80
+ function byNodeRecency(a, b) {
81
+ return a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : a.id < b.id ? -1 : 1;
82
+ }
63
83
  /**
64
84
  * 图谱命中 → L1 记录 id 序列(§D 第 3 路 RRF 的输入;纯函数,无 I/O)。
65
85
  *
66
86
  * 图谱是**可重建派生投影**,节点/fact 的 `sourceRecordIds` 是它回链 L1 的唯一凭证
67
- * (types.ts:6-7)。此处把"节点排名"翻译成"L1 记录排名":
87
+ * (types.ts:6-7)。此处把"节点排名"翻译成"L1 记录排名",分两段:
68
88
  *
69
- * - **保序**:按命中的 score 降序依次展开来源,先出现的记录 = 更高排名;
70
- * - **去重**:同一 L1 记录可能被多个节点/fact 引用,只占首次出现的位置
71
- * (重复占位会凭空抬高该记录在 RRF 里的权重);
89
+ * - **直连段**:命中节点自身 + 其 active facts 的来源(task_9 原语义、原顺序);
90
+ * - **1-hop 段**:命中节点沿 **active 边**的双向邻居(邻居需 active|disputed)的
91
+ * 节点自身来源,按邻居 updatedAt 新→旧追加,整段新增封顶
92
+ * `GRAPH_HOP_RECORD_CAP`——这是图路"关系可达"增量价值的兑现面。
93
+ * hop 段只取邻居节点自身来源,不取 fact 来源(hop 是联想不是证据,噪声从紧)。
94
+ *
95
+ * 两段共用纪律:
96
+ * - **保序**:先出现的记录 = 更高排名(直连段恒先于 hop 段);已被直连段占位的
97
+ * id 不重复计入、也**不吃 hop 名额**(关联额度不被身份来源挤占);
98
+ * - **去重**:同一 L1 记录被多个节点/fact 引用,只占首次出现的位置;
72
99
  * - **节点自身来源优先于其 fact 来源**:节点的 sourceRecordIds 是它的身份来源,
73
100
  * 而单个 fact 的来源可能引向与该查询无关的旁支记录;
74
101
  * - **只取 active fact**:superseded 是历史状态,不该把旧记录拉进当前检索结果;
75
- * - **不查库**:返回的 id 可能已被删除(墓碑边界),由调用方按"取不到的跳过"处理。
102
+ * - **不查库**:返回的 id 可能已被删除(墓碑边界),由调用方按"取不到的跳过"处理,
103
+ * 不得补空占位(占位会在 RRF 里凭空加分)。
104
+ *
105
+ * edges/nodes 由调用方传入(loadGraph 结果,百~千量级全扫即可;>2k 再考虑预建索引,
106
+ * 见文件头演进阶梯)。族过滤不在本函数:邻居节点可跨族,兜底在 graphLane 尾部的
107
+ * 记录级 family/scope 双闸(宁可漏不可串落在记录上)。
76
108
  */
77
- export function graphHitRecordIds(hits) {
109
+ export function expandHitRecordIds(hits, nodes, edges, opts) {
110
+ const asOf = typeof opts?.asOf === 'string' && opts.asOf.length > 0 ? opts.asOf : undefined;
78
111
  const seen = new Set();
79
112
  const out = [];
80
113
  const take = (id) => {
@@ -83,31 +116,85 @@ export function graphHitRecordIds(hits) {
83
116
  seen.add(id);
84
117
  out.push(id);
85
118
  };
119
+ // 直连段(节点身份来源在前,有效 facts 在后;task_5.2:asOf 下额外纳入当时成立的 superseded fact)
86
120
  for (const h of hits) {
87
121
  for (const id of h.node.sourceRecordIds)
88
122
  take(id);
89
123
  for (const f of h.node.facts) {
90
- if (f.status !== 'active')
124
+ const ok = asOf === undefined ? f.status === 'active' : factValidAtAsOf(f, asOf);
125
+ if (!ok)
91
126
  continue;
92
127
  for (const id of f.sourceRecordIds ?? [])
93
128
  take(id);
94
129
  }
95
130
  }
131
+ if (edges.length === 0)
132
+ return out;
133
+ // hop 段:可用边的双向邻居 ∉ 命中集,邻居去重,状态需 active|disputed
134
+ // (task_5.2:asOf 下"当时成立"的 superseded 边也可回链,邻居同理含当时成立的 superseded 节点)
135
+ const hitNodeIds = new Set(hits.map((h) => h.node.id));
136
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
137
+ const edgeUsable = (e) => e.status === 'active' || (asOf !== undefined && e.status === 'superseded' && coversInterval(e.validFrom, e.validTo, asOf));
138
+ const neighborUsable = (n) => n.status === 'active' || n.status === 'disputed'
139
+ || (asOf !== undefined && n.status === 'superseded' && coversInterval(n.createdAt, n.updatedAt, asOf));
140
+ const seenNeighbor = new Set();
141
+ const neighbors = [];
142
+ for (const e of edges) {
143
+ if (!edgeUsable(e))
144
+ continue;
145
+ const dirs = [
146
+ [e.fromNodeId, e.toNodeId],
147
+ [e.toNodeId, e.fromNodeId],
148
+ ];
149
+ for (const [self, other] of dirs) {
150
+ if (!hitNodeIds.has(self) || hitNodeIds.has(other) || seenNeighbor.has(other))
151
+ continue;
152
+ const n = nodeById.get(other);
153
+ if (!n || !neighborUsable(n))
154
+ continue;
155
+ seenNeighbor.add(other);
156
+ neighbors.push(n);
157
+ }
158
+ }
159
+ neighbors.sort(byNodeRecency);
160
+ let hopTaken = 0;
161
+ for (const n of neighbors) {
162
+ if (hopTaken >= GRAPH_HOP_RECORD_CAP)
163
+ break;
164
+ for (const id of n.sourceRecordIds) {
165
+ if (seen.has(id))
166
+ continue; // 已被直连段占用:不计入、不吃名额
167
+ if (hopTaken >= GRAPH_HOP_RECORD_CAP)
168
+ break;
169
+ take(id);
170
+ hopTaken++;
171
+ }
172
+ }
96
173
  return out;
97
174
  }
175
+ /**
176
+ * 直连段回链(无 hop)——保留 task_9 的原语义形状(既有测试继续钉住);
177
+ * 生产链路一律走 expandHitRecordIds(向量种子并集 + 1-hop)。
178
+ */
179
+ export function graphHitRecordIds(hits) {
180
+ return expandHitRecordIds(hits, [], []);
181
+ }
98
182
  /**
99
183
  * 图谱节点检索:返回按 score 降序的命中(最多 limit 条;limit 钳制 1~20)。
100
184
  * 候选节点 = active | disputed(superseded/archived 不进检索)。
101
185
  */
102
- export function searchGraphNodes(nodes, edges, query, limit) {
186
+ export function searchGraphNodes(nodes, edges, query, limit, opts) {
187
+ const asOf = typeof opts?.asOf === 'string' && opts.asOf.length > 0 ? opts.asOf : undefined;
103
188
  const tokens = [...new Set(tokenize(query))];
104
189
  const cappedLimit = Math.min(Math.max(Math.floor(limit) || 5, 1), GRAPH_SEARCH_LIMIT_MAX);
105
190
  if (tokens.length === 0)
106
191
  return [];
107
- // nodeId → active 关系词 token 集(先建邻接表,防逐节点全量扫边)
192
+ // nodeId → 可用关系词 token 集(先建邻接表,防逐节点全量扫边;
193
+ // task_5.1:asOf 下纳入"当时成立"的 superseded 边)
108
194
  const relationTokensByNode = new Map();
109
195
  for (const edge of edges) {
110
- if (edge.status !== 'active')
196
+ if (edge.status !== 'active'
197
+ && !(asOf !== undefined && edge.status === 'superseded' && coversInterval(edge.validFrom, edge.validTo, asOf)))
111
198
  continue;
112
199
  let set = relationTokensByNode.get(edge.fromNodeId);
113
200
  if (!set) {
@@ -126,6 +213,9 @@ export function searchGraphNodes(nodes, edges, query, limit) {
126
213
  }
127
214
  const results = [];
128
215
  for (const node of nodes) {
216
+ // 候选状态闸保持 active|disputed:task_5.1 的放宽面在 **fact/边**("纳入 superseded 且
217
+ // 区间覆盖 T 的 fact/edge")——GraphNode 没有 valid 区间列,节点主体复活无判据;
218
+ // 历史实体经"当时成立边"的邻居回链(task_5.2)重新可达,不需要翻状态。
129
219
  if (node.status !== 'active' && node.status !== 'disputed')
130
220
  continue;
131
221
  const fieldTokens = {
@@ -134,7 +224,7 @@ export function searchGraphNodes(nodes, edges, query, limit) {
134
224
  tags: new Set((node.tags ?? []).flatMap((t) => tokenize(t))),
135
225
  currentState: new Set(tokenize(node.currentState)),
136
226
  facts: new Set(node.facts
137
- .filter((f) => f.status === 'active')
227
+ .filter((f) => (asOf === undefined ? f.status === 'active' : factValidAtAsOf(f, asOf)))
138
228
  .flatMap((f) => tokenize(`${f.key} ${factValueText(f.value)}`))),
139
229
  relations: relationTokensByNode.get(node.id) ?? new Set(),
140
230
  type: new Set(tokenize(node.type)),
@@ -134,9 +134,34 @@ export declare const GRAPH_JOB_BATCH = 8;
134
134
  export declare const GRAPH_PRIORITY_NEW = 10000;
135
135
  /** 存量补投影的优先级(启动/周期补齐;恒低于新蒸馏)。 */
136
136
  export declare const GRAPH_PRIORITY_BACKFILL = 100;
137
- /** 单 job 最大尝试次数:claim 时 +1,达到即转 dead(不再重试)。 */
137
+ /** 单 job 最大尝试次数(永久错误:校验/解析类——3 次不过就 dead)。
138
+ * 瞬态错误(aborted/timeout/网络)走 GRAPH_JOB_TRANSIENT_MAX_ATTEMPTS。 */
138
139
  export declare const GRAPH_JOB_MAX_ATTEMPTS = 3;
140
+ /** 瞬态错误(attempt 不消耗到 3 即 dead):网关负载或超时引起的 aborted、
141
+ * terminated、网络抖动、模型空输出——这些与 prompt/数据无关,退避后重试即恢复。
142
+ * 6 次 ≈ 5 轮退避(~30 分钟)覆盖 local-35 负载低谷。 */
143
+ export declare const GRAPH_JOB_TRANSIENT_MAX_ATTEMPTS = 6;
144
+ /** 图谱投影 LLM 单次调用超时(含整条回退链):沿用 llm.timeoutMs(120s) 在 8-record
145
+ * 批 + 上下文节点/边 + 12 关系词 prompt 下真机撞线(14 dead 全是 aborted×13 + terminated×1)。
146
+ * 照 backfill callTimeoutMs 先例给 240s,按「路由数 × 单路由耗时」留余量。 */
147
+ export declare const GRAPH_PROJECTION_CALL_TIMEOUT_MS = 240000;
139
148
  /** 失败指数退避基数:第 n 次失败后 nextAttemptAt = now + base × 2^(n-1)。 */
140
149
  export declare const GRAPH_JOB_BACKOFF_BASE_MS = 60000;
141
150
  /** 单次检索返回上限的硬钳制(工具与 RPC 两侧共用同一钳制值)。 */
142
151
  export declare const GRAPH_SEARCH_LIMIT_MAX = 20;
152
+ /** §D 第 3 路路内向量种子的节点条数上限(种子只是联想起点,独立于词法路 limit)。 */
153
+ export declare const GRAPH_VEC_SEED_CAP = 10;
154
+ /** 1-hop 边扩展回链的新增记录数封顶(hop 段专用名额,直连段不占;防 hub 节点邻接爆量)。 */
155
+ export declare const GRAPH_HOP_RECORD_CAP = 64;
156
+ /** 面板图谱预览:种子榜上限(画布片数)。 */
157
+ export declare const GRAPH_PREVIEW_SEEDS = 6;
158
+ /** 面板图谱预览:单种子绘制的邻居上限(超出置 truncated)。 */
159
+ export declare const GRAPH_PREVIEW_NEIGHBORS = 10;
160
+ /** 面板图谱预览:种子评分中因果边的权重(score = degree + causal×此值)——
161
+ * 因果链比共现邻接更值得画,预览侧单源读 GRAPH_CAUSAL_RELATIONS。 */
162
+ export declare const GRAPH_PREVIEW_CAUSAL_WEIGHT = 2;
163
+ /** 全图星图(mode:'full')节点上限:超限截断并置 truncated(截断标记而非硬拒——
164
+ * 星图是展示面,宁可少画几颗也不报错;G6 舒适区 <2000 留余量)。 */
165
+ export declare const GRAPH_PREVIEW_FULL_NODES = 1500;
166
+ /** 全图星图 active 边上限(同一 truncated 语义;悬挂边已在截断前剔除)。 */
167
+ export declare const GRAPH_PREVIEW_FULL_EDGES = 3000;
@@ -6,9 +6,34 @@ export const GRAPH_JOB_BATCH = 8;
6
6
  export const GRAPH_PRIORITY_NEW = 10000;
7
7
  /** 存量补投影的优先级(启动/周期补齐;恒低于新蒸馏)。 */
8
8
  export const GRAPH_PRIORITY_BACKFILL = 100;
9
- /** 单 job 最大尝试次数:claim 时 +1,达到即转 dead(不再重试)。 */
9
+ /** 单 job 最大尝试次数(永久错误:校验/解析类——3 次不过就 dead)。
10
+ * 瞬态错误(aborted/timeout/网络)走 GRAPH_JOB_TRANSIENT_MAX_ATTEMPTS。 */
10
11
  export const GRAPH_JOB_MAX_ATTEMPTS = 3;
12
+ /** 瞬态错误(attempt 不消耗到 3 即 dead):网关负载或超时引起的 aborted、
13
+ * terminated、网络抖动、模型空输出——这些与 prompt/数据无关,退避后重试即恢复。
14
+ * 6 次 ≈ 5 轮退避(~30 分钟)覆盖 local-35 负载低谷。 */
15
+ export const GRAPH_JOB_TRANSIENT_MAX_ATTEMPTS = 6;
16
+ /** 图谱投影 LLM 单次调用超时(含整条回退链):沿用 llm.timeoutMs(120s) 在 8-record
17
+ * 批 + 上下文节点/边 + 12 关系词 prompt 下真机撞线(14 dead 全是 aborted×13 + terminated×1)。
18
+ * 照 backfill callTimeoutMs 先例给 240s,按「路由数 × 单路由耗时」留余量。 */
19
+ export const GRAPH_PROJECTION_CALL_TIMEOUT_MS = 240_000;
11
20
  /** 失败指数退避基数:第 n 次失败后 nextAttemptAt = now + base × 2^(n-1)。 */
12
21
  export const GRAPH_JOB_BACKOFF_BASE_MS = 60_000;
13
22
  /** 单次检索返回上限的硬钳制(工具与 RPC 两侧共用同一钳制值)。 */
14
23
  export const GRAPH_SEARCH_LIMIT_MAX = 20;
24
+ /** §D 第 3 路路内向量种子的节点条数上限(种子只是联想起点,独立于词法路 limit)。 */
25
+ export const GRAPH_VEC_SEED_CAP = 10;
26
+ /** 1-hop 边扩展回链的新增记录数封顶(hop 段专用名额,直连段不占;防 hub 节点邻接爆量)。 */
27
+ export const GRAPH_HOP_RECORD_CAP = 64;
28
+ /** 面板图谱预览:种子榜上限(画布片数)。 */
29
+ export const GRAPH_PREVIEW_SEEDS = 6;
30
+ /** 面板图谱预览:单种子绘制的邻居上限(超出置 truncated)。 */
31
+ export const GRAPH_PREVIEW_NEIGHBORS = 10;
32
+ /** 面板图谱预览:种子评分中因果边的权重(score = degree + causal×此值)——
33
+ * 因果链比共现邻接更值得画,预览侧单源读 GRAPH_CAUSAL_RELATIONS。 */
34
+ export const GRAPH_PREVIEW_CAUSAL_WEIGHT = 2;
35
+ /** 全图星图(mode:'full')节点上限:超限截断并置 truncated(截断标记而非硬拒——
36
+ * 星图是展示面,宁可少画几颗也不报错;G6 舒适区 <2000 留余量)。 */
37
+ export const GRAPH_PREVIEW_FULL_NODES = 1500;
38
+ /** 全图星图 active 边上限(同一 truncated 语义;悬挂边已在截断前剔除)。 */
39
+ export const GRAPH_PREVIEW_FULL_EDGES = 3000;
@@ -1,6 +1,7 @@
1
1
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { trace } from '../store/trace.js';
4
+ import { filterInjectCandidates } from '../store/validity.js';
4
5
  import { RecallDedupeStore } from '../store/recall-dedupe.js';
5
6
  import { OccupancyStore } from '../store/occupancy.js';
6
7
  import { expirePendingInjections, registerPendingInjection, setInjectionMarker, } from './recall-ack.js';
@@ -459,6 +460,29 @@ usage) {
459
460
  scoped = kept;
460
461
  }
461
462
  }
463
+ // ── 门2 被动注入硬当前态(expiry-tiering task_3.3/3.4,灰度闸**默认关**):
464
+ // 开时按 classifyValidity 现算态剔除 `expired`(仍在索引可检,只是不再自动
465
+ // 浮现进模型上下文);`expired_suspect` 保留并在注入行带"疑似失效"标注;
466
+ // superseded/retired 由门1 索引层结构性进不到候选(不在本层重复过滤)。
467
+ // 判据单源 `stateOfRecord`,本层不再写一份 valid_to/marker 比较(计划 §4 约束)。
468
+ // 关时不进分支零 getByIds——注入逐字与改前一致(零漂移)。
469
+ let suspectIds = new Set();
470
+ const tieringOn = cfg.validity?.tieringEnabled === true || live.get().validityTiering === true;
471
+ if (tieringOn && scoped.length > 0) {
472
+ const nowV = Date.now();
473
+ const byIdV = new Map(stores.l1.getByIds(scoped.map((h) => h.id)).map((r) => [r.id, r]));
474
+ // 主体收敛在 validity.filterInjectCandidates(纯函数可单测,task_3.6)——
475
+ // 这里只做数据装配与日志,不内联第二份判据。
476
+ const filtered = filterInjectCandidates(scoped, (id) => byIdV.get(id), nowV);
477
+ suspectIds = filtered.suspectIds;
478
+ if (filtered.exited > 0) {
479
+ logger.warn(`[memory] 门2 防御性拦截退场态候选 ${filtered.exited} 条(门1 应已挡住,出现即泄漏信号)`);
480
+ }
481
+ if (filtered.kept.length !== scoped.length) {
482
+ logger.info(`[memory] 门2 时效过滤:剔除 expired ${scoped.length - filtered.kept.length - filtered.exited} 条(agent=${payload.agent.id})`);
483
+ }
484
+ scoped = filtered.kept;
485
+ }
462
486
  // 召回去重:同会话已注入过的记录不再重复注入(模型上下文已持有,省 token)。
463
487
  // 纯过滤——剩几条注几条,全量压制(0 条新鲜命中)是正确状态而非未命中。
464
488
  // §E:压缩后增强轮跳过压制(压缩把已注入内容逐出了模型上下文)。
@@ -485,7 +509,10 @@ usage) {
485
509
  usage?.recordAttempt(scoped.map((h) => h.id), []);
486
510
  return decision;
487
511
  }
488
- const lines = applyRecallBudget(fresh.map((h) => `- [${h.scene_name ? `${h.type}|${h.scene_name}` : h.type}] ${h.content}`), { maxCharsPerMemory: cfg.recall.maxCharsPerMemory, maxTotalRecallChars: cfg.recall.maxTotalRecallChars });
512
+ const lines = applyRecallBudget(fresh.map((h) => `- [${h.scene_name ? `${h.type}|${h.scene_name}` : h.type}] ${h.content}` +
513
+ // 门2 task_3.4:疑而不隐——valid_to 来源不可信的行保留注入但带标注,
514
+ // 人/模型自行裁量。灰度关时 suspectIds 恒空,行形状逐字不变。
515
+ (suspectIds.has(h.id) ? '(疑似失效:有效期来源不可信)' : '')), { maxCharsPerMemory: cfg.recall.maxCharsPerMemory, maxTotalRecallChars: cfg.recall.maxTotalRecallChars });
489
516
  if (lines.length === 0) {
490
517
  // §F 追踪:预算清零轮(截断后无完整行)
491
518
  trace({
@@ -531,7 +558,14 @@ usage) {
531
558
  usage?.recordAttempt(scoped.map((h) => h.id), fresh.slice(0, lines.length).map((h) => h.id));
532
559
  // G-live:按 **sessionKey**(=agent.session?.id ?? agent.id,与 pill 的 sessionId / 投影 header.id 同源,
533
560
  // 非 ownerId)记入本会话“活召回集”,供侧栏卡读取;压缩时同键 reset。
534
- stores.recalled?.add(sessionKey, fresh.slice(0, lines.length).map((h) => h.id), injection.id);
561
+ // 路归因随注入透传(hybrid 融合才挂 lanes;单路策略下表空=不记,徽标无从渲染)。
562
+ const injectedHits = fresh.slice(0, lines.length);
563
+ const laneById = new Map();
564
+ for (const h of injectedHits) {
565
+ if (h.lanes !== undefined && h.lanes.length > 0)
566
+ laneById.set(h.id, h.lanes);
567
+ }
568
+ stores.recalled?.add(sessionKey, injectedHits.map((h) => h.id), injection.id, laneById);
535
569
  // 入账在成功构造注入消息之后、返回 enter 之前——任何前置抛错路径账目零扰动
536
570
  const led = ledgerFor(payload.agent.id);
537
571
  recordRecallInjection(led, text.length);
package/dist/index.js CHANGED
@@ -195,6 +195,16 @@ export async function apply(ctx, config) {
195
195
  logger.error(`[memory] 检索库打开失败,记忆功能停用: ${err instanceof Error ? err.message : String(err)}`);
196
196
  }
197
197
  }
198
+ // §D 第 3 路装配(回链 + 路内向量种子 + 1-hop,见 graph-store.searchLaneRecords):
199
+ // embed 查询惰性接 stores.l1.embedText(嵌入源运行时活切换不被钉死在启动 service 上,
200
+ // 且复用其超时/失败吞并语义——未就绪/失败 = undefined = 纯词法路,与旧 searchNodes
201
+ // 表达式逐位一致)。图谱不可用时 searchLaneRecords 自带空返回,不影响双路。
202
+ // eslint-disable-next-line prefer-const -- 声明早于创建,后续单点赋值(与 embedManagerRef 同款惰性缝;TS 不允许 const 无初值)
203
+ let laneEmbedQuery;
204
+ const graphLaneProvider = async (query, limit, family) => {
205
+ const vec = laneEmbedQuery ? await laneEmbedQuery(query).catch(() => undefined) : undefined;
206
+ return db.graphStore.searchLaneRecords(query, vec, limit, family ? [family] : undefined);
207
+ };
198
208
  const stores = {
199
209
  usage: undefined, // 装配于下方(依赖 dataDir 就绪)
200
210
  // DRSC 客户端(T8.4 读切):蒸馏背景窗 + conversation_search 工具面的历史供给。
@@ -202,8 +212,8 @@ export async function apply(ctx, config) {
202
212
  drsc: new DrscClient({ trustedHosts: () => readStartupTrustedHosts(ctx) }),
203
213
  l0: new L0Store(db),
204
214
  l1: new L1Store(dataDir, db, embed, config.recall.strategy, logger, config.recall.decayHalfLifeDays,
205
- // §D 第 3 路:图谱回链。图谱不可用时 searchNodes 自带 no-op,不影响双路。
206
- (query, limit, family) => db.graphStore.searchNodes(query, limit, family ? [family] : undefined),
215
+ // §D 第 3 路:图谱回链(声明于 stores 之前,embed 查询在下方接线)。
216
+ graphLaneProvider,
207
217
  // 投射开着才给没名字的新记录起名(“关时零动作”这条承诺不改)。
208
218
  // 传函数而不是布尔:读的是当前配置对象,不把启动那一刻的值钉进 store。
209
219
  () => config.memExport?.enabled === true),
@@ -235,6 +245,9 @@ export async function apply(ctx, config) {
235
245
  // 后台记忆后端:初始化完成后填充(worker 隔离,失败则进程内)
236
246
  backend: undefined,
237
247
  };
248
+ // 惰性缝接线:provider 在检索时才解引用(l1 已就绪);声明序上的"先声明后赋值"
249
+ // 只影响装配期,而装配期不会有检索在飞(启动未完就没有 agent 回合)。
250
+ laneEmbedQuery = (q) => stores.l1.embedText(q);
238
251
  if (storageOk) {
239
252
  try {
240
253
  await Promise.all([
@@ -386,6 +399,8 @@ export async function apply(ctx, config) {
386
399
  runner.startIdleTimer();
387
400
  // 图谱周期补投影(cfg.graph.enabled 门控;30 分钟一次低速收敛)
388
401
  runner.startGraphBackfillTimer();
402
+ // 时效标注存量回填(expiry-tiering task_6.3:tieringEnabled 门控,低频、让位蒸馏)
403
+ runner.startValidityBackfillTimer();
389
404
  // 重建控制器(存储降级时不建——RPC 端点走 supported=false 分支)
390
405
  const rebuild = storageOk && !db.isDegraded()
391
406
  ? new RebuildController(ctx, config, stores, db, runner, logger, live)
@@ -455,7 +470,12 @@ export async function apply(ctx, config) {
455
470
  stores.l1.setFileSyncHook(fileSync);
456
471
  const recall = registerRecall(ctx, config, stores, logger, live, modes, dataDir, recallUsage);
457
472
  runner.setAfterRun(recall.invalidateProfile);
458
- const toolSeals = registerMemoryTools(ctx, config, stores, logger, modes, live, ruminate, storageOk ? roomRegistry : undefined);
473
+ const toolSeals = registerMemoryTools(ctx, config, stores, logger, modes, live, ruminate, storageOk ? roomRegistry : undefined, {
474
+ // 确定性写面钩子(graph-write-plane R2/R3):runner 公开方法直连,工具三路径与
475
+ // distill 分支共用同一个入队实现,不存在第二套优先级/去重逻辑。
476
+ enqueueGraph: (ids) => runner.enqueueGraphFor(ids),
477
+ reprojectGraph: (ids) => runner.reprojectGraph(ids),
478
+ });
459
479
  // 激活槽位(active slot):工具面 + 常驻注入 + 服务端投影(均走 ctx.effect,可撤销)
460
480
  registerSlotTools(ctx, config, stores.slots, logger, modes, live, stores.l1);
461
481
  const slotRecall = registerSlotRecall(ctx, config, stores.slots, logger, live);
package/dist/llm.d.ts CHANGED
@@ -156,7 +156,12 @@ export declare function planDistillEffort(ctx: Context, provider: string, model:
156
156
  * 适配器异常地没有发 block-end 时兜底。两者都累计会把输出翻倍。
157
157
  */
158
158
  export declare function callLLM(ctx: Context, cfg: MemoryConfig, opts: LlmCallOptions): Promise<string>;
159
- /** 带诊断日志的 parseJson:解析失败时记录原始输出摘录(模型输出异常排查的关键信息)。 */
159
+ /** 带诊断日志的 parseJson:解析失败时记录原始输出摘录(模型输出异常排查的关键信息);修复/救援命中时 warn 一行。 */
160
160
  export declare function parseJsonLogged<T>(raw: string, what: string, logger?: MemoryLogger): T;
161
- /** 容错地解析 LLM 输出的 JSON(剥掉可能的 ```json 围栏)。 */
161
+ /**
162
+ * 容错地解析 LLM 输出的 JSON。
163
+ *
164
+ * 旧版行为:剥 ```json``` 围栏 + 切到首个 `[`/`{` 到末尾 `]`/`}`,一次 JSON.parse。
165
+ * 新版同路径 + 三级容错(strict → repaired → salvaged),内部实现见 util/json-repair.ts。
166
+ */
162
167
  export declare function parseJson<T>(raw: string): T;