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

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.
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([
@@ -25,6 +25,15 @@ export declare const GRAPH_CONTEXT_EDGE_LIMIT = 120;
25
25
  export declare function selectContextNodes(records: readonly MemoryRecord[], nodes: readonly GraphNode[], limit?: number): GraphNode[];
26
26
  /** 选择进入上下文的边:两端都入选节点的 active 边,按更新时间新→旧,≤120。 */
27
27
  export declare function selectContextEdges(nodes: readonly GraphNode[], edges: readonly GraphEdge[], limit?: number): GraphEdge[];
28
+ /**
29
+ * 瞬态失败判据(与永久错误分流,由 GraphStore.fail 选不同重试封顶)。
30
+ *
31
+ * 真机归因(2026-10-20 审计):14 个 dead 全是 `llm aborted: Request was aborted` ×13 +
32
+ * `llm error: terminated` ×1——零校验类失败,全部是网关负载/超时把在飞请求掉断。
33
+ * 这类错误与 prompt/数据无关,退避后重试即可恢复,不该和“模型真的产坏了”同封顶。
34
+ * 匹配串源自 callLLM/callRoute/callDirect 的实际抛出形态(小写子串包含)。
35
+ */
36
+ export declare function isTransientGraphFailure(msg: string): boolean;
28
37
  /**
29
38
  * 执行一次图谱投影(claim 一个 job → LLM → 校验落库;无 job 时静默返回)。
30
39
  * 返回是否实际认领了任务(泵据此决定是否续排)。失败转 fail(退避/封顶由
@@ -2,6 +2,7 @@ import { callLLM, parseJsonLogged, resolveLayerTokens } from '../llm.js';
2
2
  import { buildGraphProjectionPrompt, getGraphProjectionSystemPrompt, } from '../prompts/graph-projection.js';
3
3
  import { normalizeEntityName } from '../graph/apply.js';
4
4
  import { tokenize } from '../util/text.js';
5
+ import { GRAPH_PROJECTION_CALL_TIMEOUT_MS } from '../graph/types.js';
5
6
  import { errDetail } from '../util/filelog.js';
6
7
  /** claim 上下文装配的节点上限(词法相关 + 最近更新,总量封顶)。 */
7
8
  export const GRAPH_CONTEXT_NODE_LIMIT = 80;
@@ -64,6 +65,27 @@ export function selectContextEdges(nodes, edges, limit = GRAPH_CONTEXT_EDGE_LIMI
64
65
  .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0))
65
66
  .slice(0, limit);
66
67
  }
68
+ /**
69
+ * 瞬态失败判据(与永久错误分流,由 GraphStore.fail 选不同重试封顶)。
70
+ *
71
+ * 真机归因(2026-10-20 审计):14 个 dead 全是 `llm aborted: Request was aborted` ×13 +
72
+ * `llm error: terminated` ×1——零校验类失败,全部是网关负载/超时把在飞请求掉断。
73
+ * 这类错误与 prompt/数据无关,退避后重试即可恢复,不该和“模型真的产坏了”同封顶。
74
+ * 匹配串源自 callLLM/callRoute/callDirect 的实际抛出形态(小写子串包含)。
75
+ */
76
+ export function isTransientGraphFailure(msg) {
77
+ const low = msg.toLowerCase();
78
+ return (low.includes('aborted') ||
79
+ low.includes('timeout') ||
80
+ low.includes('timed out') ||
81
+ low.includes('terminated') ||
82
+ low.includes('empty output') ||
83
+ low.includes('econn') ||
84
+ low.includes('etimedout') ||
85
+ low.includes('fetch failed') ||
86
+ low.includes('socket hang') ||
87
+ low.includes('network'));
88
+ }
67
89
  /**
68
90
  * 执行一次图谱投影(claim 一个 job → LLM → 校验落库;无 job 时静默返回)。
69
91
  * 返回是否实际认领了任务(泵据此决定是否续排)。失败转 fail(退避/封顶由
@@ -84,12 +106,17 @@ export async function runGraphProjection(ctx, cfg, graphs, logger) {
84
106
  maxTokens: resolveLayerTokens(cfg, 'graph'),
85
107
  layer: 'graph',
86
108
  logger,
109
+ // 自带宽超时信号:不传则沿用 cfg.llm.timeoutMs(120s)——图谱批 prompt 在本地网关上
110
+ // 实测 ~2min/job,正好撞线(14 dead 根因);照 backfill callTimeoutMs 先例给 240s。
111
+ signal: AbortSignal.timeout(GRAPH_PROJECTION_CALL_TIMEOUT_MS),
87
112
  });
88
113
  graphs.complete(job.id, parseProjection(raw, logger), { now: new Date().toISOString() });
89
114
  }
90
115
  catch (err) {
91
- logger.warn(`[memory] 图谱投影失败(job=${job.id},attempts=${job.attempts}): ${errDetail(err)}`);
92
- graphs.fail(job.id, errDetail(err));
116
+ const detail = errDetail(err);
117
+ logger.warn(`[memory] 图谱投影失败(job=${job.id},attempts=${job.attempts}): ${detail}`);
118
+ // 瞬态(网关掉断/超时/空输出)与永久(校验/解析)分档——前者多给 3 次退避重试机会。
119
+ graphs.fail(job.id, detail, { transient: isTransientGraphFailure(detail) });
93
120
  }
94
121
  return true;
95
122
  }
package/dist/stats.js CHANGED
@@ -107,6 +107,8 @@ export const MEMORY_ENDPOINTS = [
107
107
  'dsh-memory/conflicts-rejected',
108
108
  'dsh-memory/graph-search',
109
109
  'dsh-memory/graph-node-get',
110
+ 'dsh-memory/graph-preview',
111
+ 'dsh-memory/graph-cleanup-dead',
110
112
  'dsh-memory/scenes',
111
113
  'dsh-memory/persona',
112
114
  'dsh-memory/log-tail',
@@ -474,6 +476,8 @@ export async function handleEndpoint(endpoint, payload, deps) {
474
476
  return { sessionId, records: [], typeCatalog: typeCatalogEntries() };
475
477
  // 压缩水位之上的那批:正文已被摘要取代。与 retired 正交(真实实现见 session-recalled.ts)。
476
478
  const superseded = stores.recalled?.supersededOf(sessionId);
479
+ // 路归因表(record id → 管道串):不在表里 = 无从归因,不挂 lanes 字段。
480
+ const laneTab = stores.recalled?.lanesOf(sessionId);
477
481
  const halfLife = effectiveCfg(cfg, live).recall?.decayHalfLifeDays ?? 0;
478
482
  const byId = new Map(stores.l1.getByIds(ids).map((r) => [r.id, r]));
479
483
  const records = ids
@@ -486,7 +490,11 @@ export async function handleEndpoint(endpoint, payload, deps) {
486
490
  userRelevance: stores.feedback?.meanOf(r.id) ?? null,
487
491
  userMark: stores.feedback?.lastOf(r.id) ?? null,
488
492
  });
489
- return superseded?.has(r.id) === true ? { ...ui, superseded: true } : ui;
493
+ let out = superseded?.has(r.id) === true ? { ...ui, superseded: true } : ui;
494
+ const laneStr = laneTab?.get(r.id);
495
+ if (laneStr !== undefined && laneStr.length > 0)
496
+ out = { ...out, lanes: laneStr.split('|') };
497
+ return out;
490
498
  });
491
499
  return { sessionId, records, typeCatalog: typeCatalogEntries() };
492
500
  }
@@ -2019,6 +2027,27 @@ export async function handleEndpoint(endpoint, payload, deps) {
2019
2027
  return { node: null, edges: [] };
2020
2028
  return { node, edges: graph.edgesOf(id) };
2021
2029
  }
2030
+ case 'dsh-memory/graph-preview': {
2031
+ // 面板图谱预览:缺省种子推荐榜,seedId 给定展开该邻域(悬挂/不可见→空 seeds 不抛)。
2032
+ // mode==='full' 附全图轻量块(大图星图);非 'full' 一律按缺省——无 full 键零漂移。
2033
+ const p = (payload ?? {});
2034
+ const seedId = typeof p.seedId === 'string' && p.seedId.length > 0 ? p.seedId.slice(0, 200) : undefined;
2035
+ const mode = p.mode === 'full' ? 'full' : undefined;
2036
+ const graph = stores.graph;
2037
+ if (!graph) {
2038
+ const zero = { seeds: [], stats: { nodes: 0, edges: 0, causalEdges: 0, pendingJobs: 0, jobsTotal: 0 } };
2039
+ return mode === 'full' ? { ...zero, full: { nodes: [], edges: [], truncated: false } } : zero;
2040
+ }
2041
+ return graph.buildPreview(seedId, mode);
2042
+ }
2043
+ case 'dsh-memory/graph-cleanup-dead': {
2044
+ const graph = stores.graph;
2045
+ if (!graph)
2046
+ return { removed: 0 };
2047
+ const p = (payload ?? {});
2048
+ const ms = typeof p.olderThanMs === 'number' && p.olderThanMs > 0 ? p.olderThanMs : undefined;
2049
+ return { removed: graph.cleanupDead(ms) };
2050
+ }
2022
2051
  case 'dsh-memory/scenes': {
2023
2052
  // 两族拼接展示(浏览器保持混合视图;路径冲突时后写入的族覆盖显示名,读取仍各自独立)
2024
2053
  const items = [];
@@ -1,4 +1,6 @@
1
1
  import type { DatabaseSync } from 'node:sqlite';
2
+ import { buildFullView } from '../graph/preview.js';
3
+ import type { GraphPreviewResult } from '../graph/preview.js';
2
4
  import type { GraphEdge, GraphNode, GraphNodeSearchResult, GraphProjectionJob, GraphProjectionResult } from '../graph/types.js';
3
5
  import type { MemoryFamily, MemoryLogger, MemoryRecord } from '../types.js';
4
6
  /**
@@ -99,6 +101,37 @@ export declare class GraphStore {
99
101
  * 宁可漏不可串)。降级/异常返回空数组。
100
102
  */
101
103
  searchNodes(query: string, limit: number, families?: readonly MemoryFamily[]): GraphNodeSearchResult[];
104
+ /**
105
+ * §D 第 3 路存储缝(回链记录 id 序;graphLane 直接消费):词法命中 ∪ 路内向量种子
106
+ * → 1-hop 边扩展 → 保序去重的 L1 记录 id 序列。
107
+ *
108
+ * - **向量种子是路内并集、不是新路**:embedding 由调用方传入(装配层惰性查
109
+ * l1.embedText;未就绪/失败传 undefined = 纯词法路,与旧 searchNodes+直连回链
110
+ * 逐位一致);并集不占 RRF 路数名额,"未接线 = 恰为 2 路"不变量不动;
111
+ * - **种子序**:词法命中在前、向量种子追加在后(RRF 用 rank,顺序即权重),
112
+ * 同节点去重;向量种子同样过 visible 族集与 active|disputed 状态闸(宁可漏不可串);
113
+ * - **1-hop 邻居不按族预筛**:来源记录的跨族由 graphLane 尾部记录级 family/scope
114
+ * 双闸兜底(与本路既有纪律同源);
115
+ * - 降级(db 未就绪/空图)返回空数组,不抛。
116
+ *
117
+ * 返回记录 id 而非 hits 是刻意的:回链/扩展属于读路径细节,路侧只需排名序列。
118
+ */
119
+ searchLaneRecords(query: string, embedding: Float32Array | undefined, limit: number, families?: readonly MemoryFamily[]): readonly string[];
120
+ /**
121
+ * 面板图谱预览存储缝(契约 `dsh-memory/graph-preview` 的数据源):
122
+ * loadGraph 一次 + job 计数(pending/running/failed 未定项与全量分母)传入纯函数。
123
+ * 降级(db 未就绪)返**全零结构**不抛——预览是展示面,永不拖垮端点。
124
+ * mode==='full' 时响应附 `full` 轻量块;**缺省时该键缺席**(可选键零漂移纪律,
125
+ * 不是 undefined 占位——消费方 `'full' in res` 与 JSON 形状双判据都干净)。
126
+ */
127
+ buildPreview(seedId?: string, mode?: 'full'): GraphPreviewResult & {
128
+ full?: ReturnType<typeof buildFullView>;
129
+ };
130
+ /**
131
+ * 清理历史 dead 作业行(纯审计痕迹删除,不影响已重入队的记录)。
132
+ * olderThanMs: 只删早于 now-olderThanMs 的;缺省删全部 dead。
133
+ */
134
+ cleanupDead(olderThanMs?: number): number;
102
135
  private newJobId;
103
136
  /**
104
137
  * 投影入队(按 GRAPH_JOB_BATCH 分片成多个 job;去重下推 SQL):
@@ -134,10 +167,14 @@ export declare class GraphStore {
134
167
  */
135
168
  complete(jobId: string, result: GraphProjectionResult, opts?: CompleteOptions): void;
136
169
  /**
137
- * 投影失败收尾:attempts 已在 claim 时 +1——封顶转 dead(放掉 mapping,允许
138
- * 重新入队);未封顶转 failed + 指数退避 nextAttemptAt(mapping 保留防重复入队)。
170
+ * 投影失败收尾:attempts 已在 claim 时 +1——根据错误分类选不同封顶:
171
+ * - 永久错误(校验/解析):attempts >= GRAPH_JOB_MAX_ATTEMPTS(3) → dead
172
+ * - 瞬态错误(aborted/timeout/网络):attempts >= GRAPH_JOB_TRANSIENT_MAX_ATTEMPTS(6) → dead
173
+ * dead 放掉 mapping(允许下次 backfill 重新入队);未封顶转 failed + 指数退避。
139
174
  */
140
- fail(jobId: string, error: string): void;
175
+ fail(jobId: string, error: string, opts?: {
176
+ transient?: boolean;
177
+ }): void;
141
178
  /** 启动回收:上次进程退出时卡在 running 的 job 放回 pending(dispose 缝不永久卡批)。 */
142
179
  recoverRunning(): number;
143
180
  /** 最近 job 列表(诊断/面板用;未就绪返回空)。 */
@@ -19,8 +19,9 @@
19
19
  */
20
20
  import { randomBytes } from 'node:crypto';
21
21
  import { applyGraphProjection } from '../graph/apply.js';
22
- import { searchGraphNodes } from '../graph/search.js';
23
- import { GRAPH_JOB_BACKOFF_BASE_MS, GRAPH_JOB_BATCH, GRAPH_JOB_MAX_ATTEMPTS, GRAPH_PRIORITY_BACKFILL, GRAPH_PROJECTOR_VERSION, } from '../graph/types.js';
22
+ import { expandHitRecordIds, searchGraphNodes } from '../graph/search.js';
23
+ import { buildFullView, buildGraphPreview } from '../graph/preview.js';
24
+ import { GRAPH_JOB_BACKOFF_BASE_MS, GRAPH_JOB_BATCH, GRAPH_JOB_MAX_ATTEMPTS, GRAPH_JOB_TRANSIENT_MAX_ATTEMPTS, GRAPH_PRIORITY_BACKFILL, GRAPH_PROJECTOR_VERSION, GRAPH_VEC_SEED_CAP, } from '../graph/types.js';
24
25
  import { normPersistence } from '../types.js';
25
26
  import { isZeroVector, vecToBuffer } from './vec-utils.js';
26
27
  const TAG = '[memory][graph]';
@@ -460,6 +461,98 @@ export class GraphStore {
460
461
  : nodes;
461
462
  return searchGraphNodes(filtered, edges, query, limit);
462
463
  }
464
+ /**
465
+ * §D 第 3 路存储缝(回链记录 id 序;graphLane 直接消费):词法命中 ∪ 路内向量种子
466
+ * → 1-hop 边扩展 → 保序去重的 L1 记录 id 序列。
467
+ *
468
+ * - **向量种子是路内并集、不是新路**:embedding 由调用方传入(装配层惰性查
469
+ * l1.embedText;未就绪/失败传 undefined = 纯词法路,与旧 searchNodes+直连回链
470
+ * 逐位一致);并集不占 RRF 路数名额,"未接线 = 恰为 2 路"不变量不动;
471
+ * - **种子序**:词法命中在前、向量种子追加在后(RRF 用 rank,顺序即权重),
472
+ * 同节点去重;向量种子同样过 visible 族集与 active|disputed 状态闸(宁可漏不可串);
473
+ * - **1-hop 邻居不按族预筛**:来源记录的跨族由 graphLane 尾部记录级 family/scope
474
+ * 双闸兜底(与本路既有纪律同源);
475
+ * - 降级(db 未就绪/空图)返回空数组,不抛。
476
+ *
477
+ * 返回记录 id 而非 hits 是刻意的:回链/扩展属于读路径细节,路侧只需排名序列。
478
+ */
479
+ searchLaneRecords(query, embedding, limit, families) {
480
+ if (!this.db)
481
+ return [];
482
+ const { nodes, edges } = this.loadGraph();
483
+ const visible = families && families.length > 0
484
+ ? nodes.filter((n) => n.families.some((f) => families.includes(f)))
485
+ : nodes;
486
+ const lexical = searchGraphNodes(visible, edges, query, limit);
487
+ let hits = lexical;
488
+ if (embedding && embedding.length > 0) {
489
+ const visibleIds = new Set(visible.map((n) => n.id));
490
+ const seenNodes = new Set(lexical.map((h) => h.node.id));
491
+ const extra = [];
492
+ for (const v of this.searchNodesByVector(embedding, GRAPH_VEC_SEED_CAP)) {
493
+ if (seenNodes.has(v.node.id) || !visibleIds.has(v.node.id))
494
+ continue;
495
+ if (v.node.status !== 'active' && v.node.status !== 'disputed')
496
+ continue;
497
+ seenNodes.add(v.node.id);
498
+ extra.push({ node: v.node, score: v.score, matchedFields: ['name'], matchReason: '向量种子命中' });
499
+ }
500
+ if (extra.length > 0)
501
+ hits = [...lexical, ...extra];
502
+ }
503
+ return expandHitRecordIds(hits, nodes, edges);
504
+ }
505
+ /**
506
+ * 面板图谱预览存储缝(契约 `dsh-memory/graph-preview` 的数据源):
507
+ * loadGraph 一次 + job 计数(pending/running/failed 未定项与全量分母)传入纯函数。
508
+ * 降级(db 未就绪)返**全零结构**不抛——预览是展示面,永不拖垮端点。
509
+ * mode==='full' 时响应附 `full` 轻量块;**缺省时该键缺席**(可选键零漂移纪律,
510
+ * 不是 undefined 占位——消费方 `'full' in res` 与 JSON 形状双判据都干净)。
511
+ */
512
+ buildPreview(seedId, mode) {
513
+ const zero = { seeds: [], stats: { nodes: 0, edges: 0, causalEdges: 0, pendingJobs: 0, jobsTotal: 0 } };
514
+ if (!this.db)
515
+ return mode === 'full' ? { ...zero, full: { nodes: [], edges: [], truncated: false } } : zero;
516
+ const { nodes, edges } = this.loadGraph();
517
+ let pendingJobs = 0;
518
+ let jobsTotal = 0;
519
+ let deadJobs = 0;
520
+ try {
521
+ pendingJobs = this.db
522
+ .prepare("SELECT COUNT(1) AS c FROM graph_projection_jobs WHERE status IN ('pending','running','failed')")
523
+ .get().c;
524
+ jobsTotal = this.db.prepare('SELECT COUNT(1) AS c FROM graph_projection_jobs').get().c;
525
+ deadJobs = this.db.prepare("SELECT COUNT(1) AS c FROM graph_projection_jobs WHERE status = 'dead'").get().c;
526
+ }
527
+ catch (err) {
528
+ this.logger?.warn(`${TAG} 预览 job 计数失败(计数回零): ${err instanceof Error ? err.message : String(err)}`);
529
+ }
530
+ const r = buildGraphPreview(nodes, edges, { seedId, pendingJobs, jobsTotal, deadJobs });
531
+ if (mode === 'full')
532
+ return { ...r, full: buildFullView(nodes, edges) };
533
+ return r;
534
+ }
535
+ /**
536
+ * 清理历史 dead 作业行(纯审计痕迹删除,不影响已重入队的记录)。
537
+ * olderThanMs: 只删早于 now-olderThanMs 的;缺省删全部 dead。
538
+ */
539
+ cleanupDead(olderThanMs) {
540
+ if (!this.db)
541
+ return 0;
542
+ try {
543
+ const cutoff = olderThanMs != null && olderThanMs > 0
544
+ ? new Date(Date.now() - olderThanMs).toISOString()
545
+ : undefined;
546
+ const result = cutoff
547
+ ? this.db.prepare("DELETE FROM graph_projection_jobs WHERE status = 'dead' AND updated_time < ?").run(cutoff)
548
+ : this.db.prepare("DELETE FROM graph_projection_jobs WHERE status = 'dead'").run();
549
+ return Number(result.changes ?? 0);
550
+ }
551
+ catch (err) {
552
+ this.logger?.warn(`${TAG} cleanupDead 失败(忽略): ${err instanceof Error ? err.message : String(err)}`);
553
+ return 0;
554
+ }
555
+ }
463
556
  // ============================
464
557
  // 投影 job 队列
465
558
  // ============================
@@ -568,7 +661,7 @@ export class GraphStore {
568
661
  AND status IN ('pending', 'failed')
569
662
  AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
570
663
  ORDER BY priority DESC, created_time ASC LIMIT 1`)
571
- .get(GRAPH_PROJECTOR_VERSION, GRAPH_JOB_MAX_ATTEMPTS, nowMs);
664
+ .get(GRAPH_PROJECTOR_VERSION, GRAPH_JOB_TRANSIENT_MAX_ATTEMPTS, nowMs);
572
665
  if (!row)
573
666
  return null;
574
667
  db.prepare(`UPDATE graph_projection_jobs SET status = 'running', attempts = attempts + 1, error = NULL, updated_time = ? WHERE job_id = ?`).run(nowIso, row.job_id);
@@ -645,24 +738,27 @@ export class GraphStore {
645
738
  }
646
739
  }
647
740
  /**
648
- * 投影失败收尾:attempts 已在 claim 时 +1——封顶转 dead(放掉 mapping,允许
649
- * 重新入队);未封顶转 failed + 指数退避 nextAttemptAt(mapping 保留防重复入队)。
741
+ * 投影失败收尾:attempts 已在 claim 时 +1——根据错误分类选不同封顶:
742
+ * - 永久错误(校验/解析):attempts >= GRAPH_JOB_MAX_ATTEMPTS(3) → dead
743
+ * - 瞬态错误(aborted/timeout/网络):attempts >= GRAPH_JOB_TRANSIENT_MAX_ATTEMPTS(6) → dead
744
+ * dead 放掉 mapping(允许下次 backfill 重新入队);未封顶转 failed + 指数退避。
650
745
  */
651
- fail(jobId, error) {
746
+ fail(jobId, error, opts) {
652
747
  if (!this.db)
653
748
  return;
654
749
  try {
655
750
  const db = this.db;
656
751
  const nowMs = Date.now();
657
752
  const nowIso = new Date(nowMs).toISOString();
753
+ const cap = opts?.transient ? GRAPH_JOB_TRANSIENT_MAX_ATTEMPTS : GRAPH_JOB_MAX_ATTEMPTS;
658
754
  this.tx(() => {
659
755
  const row = db.prepare('SELECT attempts FROM graph_projection_jobs WHERE job_id = ?').get(jobId);
660
756
  if (!row)
661
757
  return;
662
- if (row.attempts >= GRAPH_JOB_MAX_ATTEMPTS) {
758
+ if (row.attempts >= cap) {
663
759
  db.prepare(`UPDATE graph_projection_jobs SET status = 'dead', error = ?, updated_time = ? WHERE job_id = ?`).run(error.slice(0, 500), nowIso, jobId);
664
760
  db.prepare('DELETE FROM graph_job_records WHERE job_id = ?').run(jobId);
665
- this.logger?.warn(`${TAG} 投影任务达重试上限,标记 dead(job=${jobId}): ${error.slice(0, 200)}`);
761
+ this.logger?.warn(`${TAG} 投影任务达重试上限(${opts?.transient ? '瞬态' : '永久'}cap=${cap}),标记 dead(job=${jobId}): ${error.slice(0, 200)}`);
666
762
  }
667
763
  else {
668
764
  const backoffMs = GRAPH_JOB_BACKOFF_BASE_MS * 2 ** (row.attempts - 1);
@@ -1,5 +1,4 @@
1
1
  import type { L1Hit, MemoryFamily, MemoryLogger, MemoryRecord, RoomCount } from '../types.js';
2
- import type { GraphNodeSearchResult } from '../graph/types.js';
3
2
  import type { L1Receipt, ReceiptQuery } from './receipts.js';
4
3
  import type { ConflictClaimGroup, ConflictPair, ConflictRejected, ConflictResolution, ConflictType } from './conflicts.js';
5
4
  import { type SupersedeInfo } from './supersede.js';
@@ -9,11 +8,13 @@ import { type L1MetaLite, type MemoryDb } from './sqlite.js';
9
8
  import type { L1FileSyncHook } from './memory-md-sync.js';
10
9
  export type RecallStrategy = 'keyword' | 'embedding' | 'hybrid';
11
10
  /**
12
- * 图谱路提供者(§D 第 3 路):按查询返回图谱命中(已按 score 降序)。
11
+ * 图谱路提供者(§D 第 3 路):按查询返回图谱回链的 L1 记录 id 有序列
12
+ * (向量种子并集与 1-hop 边扩展在 provider 内完成,见 graph-store.searchLaneRecords)。
13
13
  * 抽成注入式而非直接读 `db.graphStore`,是为了给 hybrid 融合留一个可替换的测试缝,
14
- * 并让「未接线 = 恰为 2 路」成为默认行为(既有调用方零行为变化)。
14
+ * 并让「未接线 = 恰为 2 路」成为默认行为(既有调用方零行为变化)。允许返回 Promise
15
+ * 是给向量种子的 embed 查询(装配层惰性取嵌入源);异常由 graphLane 吞并降级。
15
16
  */
16
- export type GraphLaneProvider = (query: string, limit: number, family?: MemoryFamily) => readonly GraphNodeSearchResult[];
17
+ export type GraphLaneProvider = (query: string, limit: number, family?: MemoryFamily) => readonly string[] | Promise<readonly string[]>;
17
18
  export interface L1SearchOptions {
18
19
  /** 按记忆类型精确过滤(后置过滤,官方做法)。 */
19
20
  type?: string;
@@ -424,17 +425,19 @@ export declare class L1Store {
424
425
  */
425
426
  private recencyLane;
426
427
  /**
427
- * §D 第 3 路(图谱路径,hybrid 专用):图谱命中 → `sourceRecordIds` 回链 →
428
- * L1 记录,作为第 3 条**已排序**列表参与 RRF。
428
+ * §D 第 3 路(图谱路径,hybrid 专用):provider 给出回链记录 id 序(路内向量种子
429
+ * 并集 + 1-hop 边扩展在 provider 内完成),本缝只做点查回形 + family/scope 双闸,
430
+ * 作为第 3 条**已排序**列表参与 RRF。
429
431
  *
430
432
  * 为什么值得:图谱是按实体/关系组织的**可重建派生投影**,能召回词法与向量
431
433
  * 都命不中的记录(同义表述、关系可达)——这正是本路相对双路的增量。
432
434
  *
433
435
  * 三条边界:
434
436
  * - **异常降级**:图谱是派生投影,不得因它失败而拖垮主检索 → 记 warn、返回空路,
435
- * 融合退回双路(路数随之降为 2,分数回到既有量纲);
436
- * - **族隔离**:图谱节点已按族过滤,但其来源记录可能跨族 → 这里再按 `family`
437
- * 过滤一次。宁可漏不可串(与档位隔离同源,§A 的 P0 关注点);
437
+ * 融合退回双路(路数随之降为 2,分数回到既有量纲);await 在 try 内——
438
+ * provider 的 Promise reject 与同步 throw 同路径降级;
439
+ * - **族隔离**:图谱节点已按族过滤(含向量种子),但 1-hop 邻居与来源记录可能跨族
440
+ * → 这里再按 `family` 过滤一次。宁可漏不可串(与档位隔离同源,§A 的 P0 关注点);
438
441
  * - **墓碑边界**:图谱行可能回链到已被删除的 L1 记录 → 取不到就跳过,
439
442
  * 不补空占位(占位会在 RRF 里凭空加分)。
440
443
  */
package/dist/store/l1.js CHANGED
@@ -9,7 +9,6 @@
9
9
  import { existsSync, mkdirSync, promises as fs, writeFileSync } from 'node:fs';
10
10
  import * as path from 'node:path';
11
11
  import { familyForType, isScopeVisible } from '../types.js';
12
- import { graphHitRecordIds } from '../graph/search.js';
13
12
  import { isRetired } from './supersede.js';
14
13
  import { exportThenPurge, readSnapshotManifest, readSnapshotRecords, restoreL1Snapshot, selectSnapshotTargets, snapshotDirFor, listSnapshots as listSnapshotsIn } from './l1-snapshot.js';
15
14
  import { EmbedHelper, NoopEmbeddingService } from './embedding.js';
@@ -735,14 +734,35 @@ export class L1Store {
735
734
  const vecList = vecRaw ? this.db.searchL1Vector(vecRaw, candidateK, opts?.family, opts?.workspaceId) : [];
736
735
  // 第 3 路(图谱)仅在接线时**结构性存在**:未接线不占路数名额,故既有调用方
737
736
  // 仍走 2 路、得分与改动前逐位一致(见 findings.md §12.1 的路数语义)
738
- const lanes = [ftsList, vecList];
737
+ const lanes = [['fts', ftsList], ['vec', vecList]];
739
738
  if (this.graphLaneProvider)
740
- lanes.push(this.graphLane(query, candidateK, opts?.family, opts?.workspaceId));
739
+ lanes.push(['graph', await this.graphLane(query, candidateK, opts?.family, opts?.workspaceId)]);
741
740
  // 第 4 路(时效)在衰减开启时**结构性存在**;关掉衰减 = 该路不存在
742
741
  if (this.decayHalfLifeDays > 0)
743
- lanes.push(this.recencyLane([...ftsList, ...vecList]));
744
- const merged = rrfMerge(lanes, (h) => h.id);
745
- return this.postProcess(this.applyDecay(merged.map(({ rrfScore, ...h }) => ({ ...h, score: normalizeRrf(rrfScore, lanes.length) }))), opts?.type, limit);
742
+ lanes.push(['recency', this.recencyLane([...ftsList, ...vecList])]);
743
+ const laneLists = lanes.map(([, list]) => list);
744
+ // 路归因(与融合并行,不改 rrfMerge 本体):id → 命中路集合,按路构造序
745
+ // 去重追加(fts→vec→graph→recency,确定性输出)。命中对象在下方 map 里
746
+ // 新建(不往变回路的列表元素上写),挂 lanes 后随 postProcess/applyDecay 透传。
747
+ const lanesById = new Map();
748
+ for (const [name, list] of lanes) {
749
+ for (const h of list) {
750
+ const arr = lanesById.get(h.id);
751
+ if (arr) {
752
+ if (!arr.includes(name))
753
+ arr.push(name);
754
+ }
755
+ else {
756
+ lanesById.set(h.id, [name]);
757
+ }
758
+ }
759
+ }
760
+ const merged = rrfMerge(laneLists, (h) => h.id);
761
+ return this.postProcess(this.applyDecay(merged.map(({ rrfScore, ...h }) => ({
762
+ ...h,
763
+ lanes: lanesById.get(h.id),
764
+ score: normalizeRrf(rrfScore, laneLists.length),
765
+ }))), opts?.type, limit);
746
766
  }
747
767
  /**
748
768
  * §D 第 4 路(时效路,hybrid 专用):把候选池按 `applyDecayWeight` 加权后的
@@ -770,36 +790,37 @@ export class L1Store {
770
790
  return this.applyDecay(deduped);
771
791
  }
772
792
  /**
773
- * §D 第 3 路(图谱路径,hybrid 专用):图谱命中 → `sourceRecordIds` 回链 →
774
- * L1 记录,作为第 3 条**已排序**列表参与 RRF。
793
+ * §D 第 3 路(图谱路径,hybrid 专用):provider 给出回链记录 id 序(路内向量种子
794
+ * 并集 + 1-hop 边扩展在 provider 内完成),本缝只做点查回形 + family/scope 双闸,
795
+ * 作为第 3 条**已排序**列表参与 RRF。
775
796
  *
776
797
  * 为什么值得:图谱是按实体/关系组织的**可重建派生投影**,能召回词法与向量
777
798
  * 都命不中的记录(同义表述、关系可达)——这正是本路相对双路的增量。
778
799
  *
779
800
  * 三条边界:
780
801
  * - **异常降级**:图谱是派生投影,不得因它失败而拖垮主检索 → 记 warn、返回空路,
781
- * 融合退回双路(路数随之降为 2,分数回到既有量纲);
782
- * - **族隔离**:图谱节点已按族过滤,但其来源记录可能跨族 → 这里再按 `family`
783
- * 过滤一次。宁可漏不可串(与档位隔离同源,§A 的 P0 关注点);
802
+ * 融合退回双路(路数随之降为 2,分数回到既有量纲);await 在 try 内——
803
+ * provider 的 Promise reject 与同步 throw 同路径降级;
804
+ * - **族隔离**:图谱节点已按族过滤(含向量种子),但 1-hop 邻居与来源记录可能跨族
805
+ * → 这里再按 `family` 过滤一次。宁可漏不可串(与档位隔离同源,§A 的 P0 关注点);
784
806
  * - **墓碑边界**:图谱行可能回链到已被删除的 L1 记录 → 取不到就跳过,
785
807
  * 不补空占位(占位会在 RRF 里凭空加分)。
786
808
  */
787
- graphLane(query, limit, family, workspaceId) {
809
+ async graphLane(query, limit, family, workspaceId) {
788
810
  const provider = this.graphLaneProvider;
789
811
  if (!provider)
790
812
  return [];
791
- let hits;
813
+ let ids;
792
814
  try {
793
- hits = provider(query, limit, family);
815
+ ids = await provider(query, limit, family);
794
816
  }
795
817
  catch (err) {
796
818
  this.logger?.warn(`[memory] 图谱路检索失败,本轮退回双路: ${err instanceof Error ? err.message : String(err)}`);
797
819
  return [];
798
820
  }
799
- const ids = graphHitRecordIds(hits);
800
821
  if (ids.length === 0)
801
822
  return [];
802
- const byId = new Map(this.db.getL1ByIds(ids).map((r) => [r.id, r]));
823
+ const byId = new Map(this.db.getL1ByIds([...ids]).map((r) => [r.id, r]));
803
824
  const out = [];
804
825
  for (const id of ids) {
805
826
  const r = byId.get(id);
@@ -1,4 +1,4 @@
1
- import type { MemoryLogger } from '../types.js';
1
+ import type { MemoryLogger, RecallLane } from '../types.js';
2
2
  export declare class SessionRecalledStore {
3
3
  private readonly logger?;
4
4
  private readonly file;
@@ -12,8 +12,9 @@ export declare class SessionRecalledStore {
12
12
  constructor(dataDir: string, logger?: MemoryLogger | undefined);
13
13
  revision(): number;
14
14
  private init;
15
- /** 记入本 sessionKey 本轮实际注入的记录 id(写穿;插入序淘汰最旧)。 */
16
- add(sessionKey: string, recordIds: readonly string[], injectionId?: string): void;
15
+ /** 记入本 sessionKey 本轮实际注入的记录 id(写穿;插入序淘汰最旧)。
16
+ * laneById 只带真正有归因的条目(hybrid 融合才挂 lanes;单路/旧路不传)。 */
17
+ add(sessionKey: string, recordIds: readonly string[], injectionId?: string, laneById?: ReadonlyMap<string, readonly RecallLane[]>): void;
17
18
  /**
18
19
  * 标为受保护(判过时/软退场):条目留在列表里,且不受上限淘汰与压缩清理影响。
19
20
  * id 不在本会话列表里时忽略(不凭这个调用新建条目)。
@@ -34,6 +35,11 @@ export declare class SessionRecalledStore {
34
35
  * 未出现过取代的会话返回共享空集,不每次新建)。
35
36
  */
36
37
  supersededOf(sessionKey: string): ReadonlySet<string>;
38
+ /**
39
+ * 本会话的路归因表(record id → 管道串;只读共享,投影/端点用)。
40
+ * 不在表里 = 无从归因(单路检索/旧文件条目),展示层不渲染徽标。
41
+ */
42
+ lanesOf(sessionKey: string): ReadonlyMap<string, string>;
37
43
  /**
38
44
  * 用会话日志的可见性事实**对账**取代标记(Phase 0B)。
39
45
  *
@@ -23,9 +23,12 @@ import { SESSION_RECALLED_FILE_VERSION } from './file-versions.js';
23
23
  import { RECALL_DEDUPE_IDS_CAP, RECALL_DEDUPE_SESSION_CAP } from './recall-dedupe.js';
24
24
  /** 共享空集(未出现过取代的会话重复取用,不必每次新建)。 */
25
25
  const EMPTY_SET = new Set();
26
+ /** 共享空归因表(同 EMPTY_SET 口径)。 */
27
+ const EMPTY_LANES = new Map();
26
28
  /**
27
- * 读 `injectedBy` 的宽容解析:非对象/值非字符串一律丢弃。
28
- * 宁可让那条记录“无从判”(对账不动它),也不把脏值喂给可见性判定——
29
+ * 读 `Record<string, string>` 型字段的宽容解析(injectedBy/lanes 共用):
30
+ * 非对象/值非字符串一律丢弃。
31
+ * 宁可让那条记录“无从判”(对账不动它/徽标不渲染),也不把脏值喂给下游判定——
29
32
  * 一个错的 message id 会把仍在本上下文里的正文误标成“已被摘要取代”。
30
33
  */
31
34
  function toIdMap(raw) {
@@ -93,17 +96,19 @@ export class SessionRecalledStore {
93
96
  protectedIds: new Set(Array.isArray(entry.protectedIds) ? entry.protectedIds : []),
94
97
  supersededIds: new Set(Array.isArray(entry.supersededIds) ? entry.supersededIds : []),
95
98
  injectedBy: toIdMap(entry.injectedBy),
99
+ laneOf: toIdMap(entry.lanes),
96
100
  updatedAt: entry.updatedAt ?? now,
97
101
  });
98
102
  }
99
103
  }
100
- /** 记入本 sessionKey 本轮实际注入的记录 id(写穿;插入序淘汰最旧)。 */
101
- add(sessionKey, recordIds, injectionId) {
104
+ /** 记入本 sessionKey 本轮实际注入的记录 id(写穿;插入序淘汰最旧)。
105
+ * laneById 只带真正有归因的条目(hybrid 融合才挂 lanes;单路/旧路不传)。 */
106
+ add(sessionKey, recordIds, injectionId, laneById) {
102
107
  if (!sessionKey || recordIds.length === 0)
103
108
  return;
104
109
  let entry = this.entries.get(sessionKey);
105
110
  if (!entry) {
106
- entry = { ids: new Set(), protectedIds: new Set(), supersededIds: new Set(), injectedBy: new Map(), updatedAt: 0 };
111
+ entry = { ids: new Set(), protectedIds: new Set(), supersededIds: new Set(), injectedBy: new Map(), laneOf: new Map(), updatedAt: 0 };
107
112
  this.entries.set(sessionKey, entry);
108
113
  }
109
114
  const stamp = typeof injectionId === 'string' && injectionId.length > 0 ? injectionId : undefined;
@@ -114,6 +119,11 @@ export class SessionRecalledStore {
114
119
  // 记下这次注入落在哪条消息上:没有它,对账就无从判“这条正文还在不在”。
115
120
  if (stamp !== undefined)
116
121
  entry.injectedBy.set(id, stamp);
122
+ // 路归因:最后一次注入赢;本轮无归因时**不清旧值**(徽标宁可残留一轮
123
+ // 也不要闪无——真实路集在下一次带归因注入时自然覆盖)。
124
+ const laneArr = laneById?.get(id);
125
+ if (laneArr !== undefined && laneArr.length > 0)
126
+ entry.laneOf.set(id, laneArr.join('|'));
117
127
  // 淘汰只能淘汰**没被判过时的**:受保护条目数本身超顶时宁可超顶,也不能把人的裁决
118
128
  // 凭证挤掉(那等于“因为后面又召回了几条,所以之前那条的判定历史丢了”)。
119
129
  while (entry.ids.size > RECALL_DEDUPE_IDS_CAP) {
@@ -124,6 +134,7 @@ export class SessionRecalledStore {
124
134
  entry.protectedIds.delete(oldest);
125
135
  entry.supersededIds.delete(oldest);
126
136
  entry.injectedBy.delete(oldest);
137
+ entry.laneOf.delete(oldest);
127
138
  }
128
139
  }
129
140
  entry.updatedAt = Date.now();
@@ -197,6 +208,13 @@ export class SessionRecalledStore {
197
208
  supersededOf(sessionKey) {
198
209
  return this.entries.get(sessionKey)?.supersededIds ?? EMPTY_SET;
199
210
  }
211
+ /**
212
+ * 本会话的路归因表(record id → 管道串;只读共享,投影/端点用)。
213
+ * 不在表里 = 无从归因(单路检索/旧文件条目),展示层不渲染徽标。
214
+ */
215
+ lanesOf(sessionKey) {
216
+ return this.entries.get(sessionKey)?.laneOf ?? EMPTY_LANES;
217
+ }
200
218
  /**
201
219
  * 用会话日志的可见性事实**对账**取代标记(Phase 0B)。
202
220
  *
@@ -302,6 +320,7 @@ export class SessionRecalledStore {
302
320
  ...(e.protectedIds.size > 0 ? { protectedIds: [...e.protectedIds] } : {}),
303
321
  ...(e.supersededIds.size > 0 ? { supersededIds: [...e.supersededIds] } : {}),
304
322
  ...(e.injectedBy.size > 0 ? { injectedBy: Object.fromEntries(e.injectedBy) } : {}),
323
+ ...(e.laneOf.size > 0 ? { lanes: Object.fromEntries(e.laneOf) } : {}),
305
324
  updatedAt: e.updatedAt,
306
325
  };
307
326
  }