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/client.js +36785 -134
- package/dist/contract.d.ts +74 -0
- package/dist/graph/constraints.d.ts +3 -1
- package/dist/graph/constraints.js +4 -2
- package/dist/graph/preview.d.ts +92 -0
- package/dist/graph/preview.js +186 -0
- package/dist/graph/search.d.ts +22 -5
- package/dist/graph/search.js +69 -7
- package/dist/graph/types.d.ts +26 -1
- package/dist/graph/types.js +26 -1
- package/dist/hooks/recall.js +8 -1
- package/dist/index.js +15 -2
- package/dist/pipeline/graph.d.ts +9 -0
- package/dist/pipeline/graph.js +29 -2
- package/dist/stats.js +30 -1
- package/dist/store/graph-store.d.ts +40 -3
- package/dist/store/graph-store.js +104 -8
- package/dist/store/l1.d.ts +12 -9
- package/dist/store/l1.js +37 -16
- package/dist/store/session-recalled.d.ts +9 -3
- package/dist/store/session-recalled.js +24 -5
- package/dist/types.d.ts +9 -0
- package/package.json +4 -3
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
|
|
206
|
-
|
|
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([
|
package/dist/pipeline/graph.d.ts
CHANGED
|
@@ -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(退避/封顶由
|
package/dist/pipeline/graph.js
CHANGED
|
@@ -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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
138
|
-
*
|
|
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
|
|
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 {
|
|
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,
|
|
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
|
|
649
|
-
*
|
|
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 >=
|
|
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}
|
|
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);
|
package/dist/store/l1.d.ts
CHANGED
|
@@ -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 路)
|
|
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
|
|
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 专用)
|
|
428
|
-
*
|
|
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
|
-
*
|
|
437
|
-
*
|
|
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
|
|
745
|
-
|
|
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 专用)
|
|
774
|
-
*
|
|
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
|
-
*
|
|
783
|
-
*
|
|
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
|
|
813
|
+
let ids;
|
|
792
814
|
try {
|
|
793
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|