dsh-prime-memory 0.26.4 → 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.
Files changed (42) hide show
  1. package/dist/client.js +36785 -134
  2. package/dist/clients/drsc.d.ts +93 -0
  3. package/dist/clients/drsc.js +139 -0
  4. package/dist/contract.d.ts +74 -0
  5. package/dist/drs-anchor-backfill.d.ts +88 -0
  6. package/dist/drs-anchor-backfill.js +150 -0
  7. package/dist/graph/constraints.d.ts +3 -1
  8. package/dist/graph/constraints.js +4 -2
  9. package/dist/graph/preview.d.ts +92 -0
  10. package/dist/graph/preview.js +186 -0
  11. package/dist/graph/search.d.ts +22 -5
  12. package/dist/graph/search.js +69 -7
  13. package/dist/graph/types.d.ts +26 -1
  14. package/dist/graph/types.js +26 -1
  15. package/dist/hooks/capture.d.ts +4 -4
  16. package/dist/hooks/capture.js +34 -27
  17. package/dist/hooks/recall.js +8 -1
  18. package/dist/index.js +58 -20
  19. package/dist/pipeline/graph.d.ts +9 -0
  20. package/dist/pipeline/graph.js +29 -2
  21. package/dist/pipeline/runner.d.ts +17 -4
  22. package/dist/pipeline/runner.js +33 -6
  23. package/dist/stats.js +30 -1
  24. package/dist/store/embedding-source.d.ts +1 -3
  25. package/dist/store/embedding-source.js +6 -13
  26. package/dist/store/graph-store.d.ts +40 -3
  27. package/dist/store/graph-store.js +104 -8
  28. package/dist/store/l0-watermark.d.ts +46 -0
  29. package/dist/store/l0-watermark.js +93 -0
  30. package/dist/store/l0.d.ts +12 -42
  31. package/dist/store/l0.js +2 -208
  32. package/dist/store/l1.d.ts +12 -9
  33. package/dist/store/l1.js +37 -16
  34. package/dist/store/session-recalled.d.ts +9 -3
  35. package/dist/store/session-recalled.js +24 -5
  36. package/dist/store/sqlite.d.ts +3 -0
  37. package/dist/store/sqlite.js +7 -0
  38. package/dist/tools/index.d.ts +3 -2
  39. package/dist/tools/index.js +27 -10
  40. package/dist/types.d.ts +9 -0
  41. package/dsh.plugin.json +1 -1
  42. package/package.json +8 -3
@@ -531,7 +531,14 @@ usage) {
531
531
  usage?.recordAttempt(scoped.map((h) => h.id), fresh.slice(0, lines.length).map((h) => h.id));
532
532
  // G-live:按 **sessionKey**(=agent.session?.id ?? agent.id,与 pill 的 sessionId / 投影 header.id 同源,
533
533
  // 非 ownerId)记入本会话“活召回集”,供侧栏卡读取;压缩时同键 reset。
534
- stores.recalled?.add(sessionKey, fresh.slice(0, lines.length).map((h) => h.id), injection.id);
534
+ // 路归因随注入透传(hybrid 融合才挂 lanes;单路策略下表空=不记,徽标无从渲染)。
535
+ const injectedHits = fresh.slice(0, lines.length);
536
+ const laneById = new Map();
537
+ for (const h of injectedHits) {
538
+ if (h.lanes !== undefined && h.lanes.length > 0)
539
+ laneById.set(h.id, h.lanes);
540
+ }
541
+ stores.recalled?.add(sessionKey, injectedHits.map((h) => h.id), injection.id, laneById);
535
542
  // 入账在成功构造注入消息之后、返回 enter 之前——任何前置抛错路径账目零扰动
536
543
  const led = ledgerFor(payload.agent.id);
537
544
  recordRecallInjection(led, text.length);
package/dist/index.js CHANGED
@@ -50,6 +50,7 @@ import { makeFileSyncHook } from './store/memory-md-sync.js';
50
50
  import { ObsoleteNoticeStore } from './store/obsolete-notices.js';
51
51
  import { registerSlotsProjection } from './projection/slots.js';
52
52
  import { registerRecallProjection } from './projection/recall.js';
53
+ import { DrscClient } from './clients/drsc.js';
53
54
  import { errDetail, withFileLog } from './util/filelog.js';
54
55
  import { buildRouteChain, resolveModelRoute, invalidateEffortCache } from './llm.js';
55
56
  import { effectiveCfg } from './pipeline/runner.js';
@@ -57,6 +58,30 @@ import { pendingPathFor } from './store/pending.js';
57
58
  import { initTokenCost, resetTokenCost } from './token-cost.js';
58
59
  import { initTraceStore } from './store/trace.js';
59
60
  export const name = 'dsh-memory-plugin';
61
+ /**
62
+ * webStartup(0.2.1-a.2 起更名自 webRuntime)的 best-effort 读取:trustedHosts
63
+ * 供 DrscClient 同源基址解析。宿主服务缺席/未声明 inject 时属性读会抛
64
+ * (cordis Proxy 面),双名回退链 + 两层 try,安全返回 undefined(→ 缺省 8080 口)。
65
+ */
66
+ function readStartupTrustedHosts(ctx) {
67
+ for (const name of ['webStartup', 'webRuntime']) {
68
+ try {
69
+ const direct = ctx[name];
70
+ const svc = (direct !== undefined
71
+ ? direct
72
+ : typeof ctx.get === 'function'
73
+ ? ctx.get(name)
74
+ : undefined);
75
+ if (svc !== null && typeof svc === 'object' && Array.isArray(svc.trustedHosts)) {
76
+ return svc.trustedHosts;
77
+ }
78
+ }
79
+ catch {
80
+ /* 未声明服务的属性读抛:落下一名/缺省 */
81
+ }
82
+ }
83
+ return undefined;
84
+ }
60
85
  /**
61
86
  * 硬依赖:蒸馏要用 llm,工具注册要用 tools,召回注入要用 systemPrompt。
62
87
  * 0.1.5 起 connection.rpc.handle() 把 /rpc 路由登记到 webServer 时按**调用方
@@ -170,12 +195,25 @@ export async function apply(ctx, config) {
170
195
  logger.error(`[memory] 检索库打开失败,记忆功能停用: ${err instanceof Error ? err.message : String(err)}`);
171
196
  }
172
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
+ };
173
208
  const stores = {
174
209
  usage: undefined, // 装配于下方(依赖 dataDir 就绪)
175
- l0: new L0Store(dataDir, db, embed, logger),
210
+ // DRSC 客户端(T8.4 读切):蒸馏背景窗 + conversation_search 工具面的历史供给。
211
+ // 硬依赖无本地回退(l0 表退役);trustedHosts 惰性读取(webStartup 可能晚到)。
212
+ drsc: new DrscClient({ trustedHosts: () => readStartupTrustedHosts(ctx) }),
213
+ l0: new L0Store(db),
176
214
  l1: new L1Store(dataDir, db, embed, config.recall.strategy, logger, config.recall.decayHalfLifeDays,
177
- // §D 第 3 路:图谱回链。图谱不可用时 searchNodes 自带 no-op,不影响双路。
178
- (query, limit, family) => db.graphStore.searchNodes(query, limit, family ? [family] : undefined),
215
+ // §D 第 3 路:图谱回链(声明于 stores 之前,embed 查询在下方接线)。
216
+ graphLaneProvider,
179
217
  // 投射开着才给没名字的新记录起名(“关时零动作”这条承诺不改)。
180
218
  // 传函数而不是布尔:读的是当前配置对象,不把启动那一刻的值钉进 store。
181
219
  () => config.memExport?.enabled === true),
@@ -207,10 +245,12 @@ export async function apply(ctx, config) {
207
245
  // 后台记忆后端:初始化完成后填充(worker 隔离,失败则进程内)
208
246
  backend: undefined,
209
247
  };
248
+ // 惰性缝接线:provider 在检索时才解引用(l1 已就绪);声明序上的"先声明后赋值"
249
+ // 只影响装配期,而装配期不会有检索在飞(启动未完就没有 agent 回合)。
250
+ laneEmbedQuery = (q) => stores.l1.embedText(q);
210
251
  if (storageOk) {
211
252
  try {
212
253
  await Promise.all([
213
- stores.l0.init(),
214
254
  stores.l1.init(),
215
255
  stores.scenes.chat.init(),
216
256
  stores.scenes.work.init(),
@@ -255,18 +295,18 @@ export async function apply(ctx, config) {
255
295
  // 增量:配置变化路径向量表已被 drop,全部记录均"缺失",等效全量;
256
296
  // 活切换发起时让路(swap 会 drop 表,并发跑只是白烧嵌入调用)
257
297
  const shouldCancel = () => disposed || !!embedManagerRef?.isBusy();
298
+ // T8.4:l0 向量腿随写停退役(表清空后无缺失,无需重建)
258
299
  const r1 = await stores.l1.reindex({ shouldCancel });
259
- const r0 = await stores.l0.reindex({ shouldCancel });
260
300
  // missing 复查:嵌入服务未就绪时 reindex 短路 0/0/0——那是"没跑"不是"成功",
261
301
  // 不得标记 meta(否则启动链不再补,补齐被无限推迟到 backfill)
262
- const missingAfter = db.countL1VecMissing(db.getVecSkipSet('l1')) + db.countL0VecMissing(db.getVecSkipSet('l0'));
263
- if (r1.failed === 0 && r0.failed === 0 && missingAfter === 0) {
302
+ const missingAfter = db.countL1VecMissing(db.getVecSkipSet('l1'));
303
+ if (r1.failed === 0 && missingAfter === 0) {
264
304
  db.markEmbeddingSynced(currentProviderInfo() ?? info);
265
- const skipNote = r1.skipped + r0.skipped > 0 ? `,跳过不可嵌入 ${r1.skipped + r0.skipped} 条` : '';
266
- logger.info(`[memory] 向量重建完成:L1 ${r1.written} 条,L0 ${r0.written} 条${skipNote}(原因:${dbInit.reason ?? '配置变化'})`);
305
+ const skipNote = r1.skipped > 0 ? `,跳过不可嵌入 ${r1.skipped} 条` : '';
306
+ logger.info(`[memory] 向量重建完成:L1 ${r1.written} 条${skipNote}(原因:${dbInit.reason ?? '配置变化'})`);
267
307
  }
268
308
  else {
269
- logger.warn(`[memory] 向量重建未完成(L1 失败 ${r1.failed},L0 失败 ${r0.failed},剩缺 ${Math.max(missingAfter, 0)} 条),下次启动/补齐周期重试`);
309
+ logger.warn(`[memory] 向量重建未完成(L1 失败 ${r1.failed},剩缺 ${Math.max(missingAfter, 0)} 条),下次启动/补齐周期重试`);
270
310
  }
271
311
  }
272
312
  catch (err) {
@@ -302,19 +342,18 @@ export async function apply(ctx, config) {
302
342
  if (embedManagerRef?.isBusy())
303
343
  return;
304
344
  const l1Missing = db.countL1VecMissing(db.getVecSkipSet('l1')) > 0;
305
- const l0Missing = db.countL0VecMissing(db.getVecSkipSet('l0')) > 0;
306
- if (!l1Missing && !l0Missing)
345
+ if (!l1Missing)
307
346
  return;
308
347
  const shouldCancel = () => disposed || !!embedManagerRef?.isBusy();
348
+ // T8.4:l0 向量腿随写停退役
309
349
  const r1 = await stores.l1.reindex({ shouldCancel });
310
- const r0 = await stores.l0.reindex({ shouldCancel });
311
- const missingAfter = db.countL1VecMissing(db.getVecSkipSet('l1')) + db.countL0VecMissing(db.getVecSkipSet('l0'));
312
- if (r1.failed === 0 && r0.failed === 0 && missingAfter === 0) {
350
+ const missingAfter = db.countL1VecMissing(db.getVecSkipSet('l1'));
351
+ if (r1.failed === 0 && missingAfter === 0) {
313
352
  db.markEmbeddingSynced(infoNow);
314
- logger.info(`[memory] 向量补齐完成:L1 ${r1.written} 条,L0 ${r0.written} 条(跳过不可嵌入 ${r1.skipped + r0.skipped} 条)`);
353
+ logger.info(`[memory] 向量补齐完成:L1 ${r1.written} 条(跳过不可嵌入 ${r1.skipped} 条)`);
315
354
  }
316
- else if (r1.failed > 0 || r0.failed > 0) {
317
- logger.warn(`[memory] 向量补齐未完成(L1 失败 ${r1.failed},L0 失败 ${r0.failed}),下个周期重试`);
355
+ else if (r1.failed > 0) {
356
+ logger.warn(`[memory] 向量补齐未完成(L1 失败 ${r1.failed}),下个周期重试`);
318
357
  }
319
358
  }
320
359
  catch (err) {
@@ -342,7 +381,6 @@ export async function apply(ctx, config) {
342
381
  dataDir,
343
382
  cfg: config,
344
383
  db,
345
- l0: stores.l0,
346
384
  l1: stores.l1,
347
385
  sourceStore,
348
386
  installer,
@@ -388,7 +426,7 @@ export async function apply(ctx, config) {
388
426
  : undefined;
389
427
  let flushL0;
390
428
  if (storageOk) {
391
- flushL0 = registerCapture(ctx, config, runner, stores.l0, logger, live, modes);
429
+ flushL0 = registerCapture(ctx, config, runner, db.l0Watermark, logger, live, modes);
392
430
  }
393
431
  // 召回使用统计(进 topN 才算 used;热路径只碰内存,去抖落盘)
394
432
  const recallUsage = new RecallUsageStore(dataDir, logger);
@@ -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
  }
@@ -14,8 +14,8 @@
14
14
  import type { Context } from '@deepseek-ai/cordis';
15
15
  import { type MemoryConfig } from '../config.js';
16
16
  import type { LiveSettingsHandle } from '../settings.js';
17
+ import type { DrscClient } from '../clients/drsc.js';
17
18
  import type { GraphStore } from '../store/graph-store.js';
18
- import type { L0Store } from '../store/l0.js';
19
19
  import type { L1Store } from '../store/l1.js';
20
20
  import type { PersonaStore } from '../store/persona.js';
21
21
  import type { SceneStore } from '../store/scenes.js';
@@ -23,7 +23,11 @@ import type { StateStore } from '../store/state.js';
23
23
  import type { ConversationMessage, ExtractMode, MemoryFamily, MemoryLogger } from '../types.js';
24
24
  import type { FamilyStates } from './l1.js';
25
25
  export interface MemoryStores {
26
- l0: L0Store;
26
+ /**
27
+ * DRSC 客户端(T8.4 读切):蒸馏背景窗的历史供给面(op=session&tail&redact=1)。
28
+ * 缺席(测试缝/未装配)时背景窗为空数组;拉取失败空窗降级,不抛穿管线。
29
+ */
30
+ drsc?: DrscClient;
27
31
  l1: L1Store;
28
32
  scenes: Record<MemoryFamily, SceneStore>;
29
33
  persona: Record<MemoryFamily, PersonaStore>;
@@ -171,10 +175,19 @@ export declare class MemoryRunner {
171
175
  * 蒸馏尝试时反复整量序列化落盘(多 MB 级 IO);重建轮豁免维持。 */
172
176
  private persistPending;
173
177
  private runTurn;
178
+ /**
179
+ * 蒸馏背景窗(T8.4 读切,task_19):DRSC 会话尾窗(redact 臂)→ ConversationMessage。
180
+ * - 形状映射:segment 行 → 消息(drscRowToConversationMessage;非 user/assistant 段跳过);
181
+ * - 坐标剔除:切片自身轮次整轮剔除(尾窗在 DRS 30s interval 同步后会含刚捕获
182
+ * 的 turn——旧 l0 读面按消息 id 剔除,跨系统 id 不同构,按坐标等价替身);
183
+ * - 报错口径:collector 缺席/超时 → warn 后空窗降级,**不抛穿蒸馏管线**
184
+ * (工具面 conversation_search 的显式报错口径在 tools 层分清,spec R7)。
185
+ */
186
+ private drscBackground;
174
187
  /**
175
188
  * 抽取并消费一个会话切片:成功才把切片移出桶并推进爬坡阈值;失败保留切片待重试。
176
- * 调用方已保证切片达到生效阈值(或 force)。背景参考按会话从 L0 现查并剔除切片
177
- * 自身(ADR-0003:会话间互不污染、重启不丢背景)。
189
+ * 调用方已保证切片达到生效阈值(或 force)。背景参考按会话从 DRSC 尾窗现查并
190
+ * 剔除切片自身轮次(ADR-0003:会话间互不污染、重启不丢背景)。
178
191
  */
179
192
  private extractSessionSlice;
180
193
  }
@@ -1,10 +1,11 @@
1
1
  import { resolveDataDir } from '../config.js';
2
2
  import { GRAPH_PRIORITY_NEW } from '../graph/types.js';
3
+ import { drscRowToConversationMessage } from '../clients/drsc.js';
3
4
  import { trace } from '../store/trace.js';
4
5
  import { emptyPending, freshWarmup, groupPendingBySession, loadPending, PENDING_MODES, pendingPathFor, savePending, } from '../store/pending.js';
5
6
  import { errDetail } from '../util/filelog.js';
6
7
  import { sessionWorkspaceIdOf } from '../workspace.js';
7
- import { advanceWarmupThreshold, effectiveExtractThreshold, extractionBackoffMs, idleSessionsToFlush, modeSwitchAction, pickSessionBackground, } from './trigger.js';
8
+ import { advanceWarmupThreshold, effectiveExtractThreshold, extractionBackoffMs, idleSessionsToFlush, modeSwitchAction, } from './trigger.js';
8
9
  import { runExtraction } from './l1.js';
9
10
  import { buildAnchorMap } from './anchors.js';
10
11
  import { runGraphProjection } from './graph.js';
@@ -666,10 +667,34 @@ export class MemoryRunner {
666
667
  this.afterRun?.();
667
668
  return newRecords.length;
668
669
  }
670
+ /**
671
+ * 蒸馏背景窗(T8.4 读切,task_19):DRSC 会话尾窗(redact 臂)→ ConversationMessage。
672
+ * - 形状映射:segment 行 → 消息(drscRowToConversationMessage;非 user/assistant 段跳过);
673
+ * - 坐标剔除:切片自身轮次整轮剔除(尾窗在 DRS 30s interval 同步后会含刚捕获
674
+ * 的 turn——旧 l0 读面按消息 id 剔除,跨系统 id 不同构,按坐标等价替身);
675
+ * - 报错口径:collector 缺席/超时 → warn 后空窗降级,**不抛穿蒸馏管线**
676
+ * (工具面 conversation_search 的显式报错口径在 tools 层分清,spec R7)。
677
+ */
678
+ async drscBackground(sessionId, n, sliceTurns) {
679
+ if (n <= 0)
680
+ return [];
681
+ try {
682
+ const rows = await this.stores.drsc.sessionTail(sessionId, n);
683
+ return rows
684
+ .map((r) => drscRowToConversationMessage(r, sessionId))
685
+ .filter((m) => m !== undefined)
686
+ .filter((m) => m.anchor === undefined || !sliceTurns.has(m.anchor.turn))
687
+ .slice(-n);
688
+ }
689
+ catch (err) {
690
+ this.logger.warn(`[memory] 蒸馏背景窗拉取失败(DRSC),本轮空窗降级: ${errDetail(err)}`);
691
+ return [];
692
+ }
693
+ }
669
694
  /**
670
695
  * 抽取并消费一个会话切片:成功才把切片移出桶并推进爬坡阈值;失败保留切片待重试。
671
- * 调用方已保证切片达到生效阈值(或 force)。背景参考按会话从 L0 现查并剔除切片
672
- * 自身(ADR-0003:会话间互不污染、重启不丢背景)。
696
+ * 调用方已保证切片达到生效阈值(或 force)。背景参考按会话从 DRSC 尾窗现查并
697
+ * 剔除切片自身轮次(ADR-0003:会话间互不污染、重启不丢背景)。
673
698
  */
674
699
  async extractSessionSlice(sessionId, mode, cfg, effectiveThreshold, opts) {
675
700
  const bucket = this.pending[mode];
@@ -678,9 +703,11 @@ export class MemoryRunner {
678
703
  return [];
679
704
  const rest = bucket.filter((m) => m.sessionId !== sessionId);
680
705
  try {
681
- // 背景参考:该会话最近消息(多取切片条数补偿被剔除者),剔除切片自身
682
- const background = this.stores.l0
683
- ? pickSessionBackground(await this.stores.l0.recentBySession(sessionId, cfg.extract.backgroundMessages + slice.length), new Set(slice.map((m) => m.id)), cfg.extract.backgroundMessages)
706
+ // 背景参考(T8.4 读切):DRSC 会话尾窗(redact 臂)按会话现查(ADR-0003 等价),
707
+ // 剔除切片自身轮次(切片消息可能已进 DRS 尾窗——30s interval 同步后)。
708
+ // 多取切片条数补偿被剔除者,与旧 l0 读面同口径。
709
+ const background = this.stores.drsc
710
+ ? await this.drscBackground(sessionId, cfg.extract.backgroundMessages + slice.length, new Set(slice.map((m) => m.anchor?.turn).filter((t) => typeof t === 'number')))
684
711
  : [];
685
712
  const t = Date.now();
686
713
  // R7:锚点映射同时收切片与背景——模型偶尔会引用背景消息的 id,收了它
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,7 +1,6 @@
1
1
  import type { MemoryConfig } from '../config.js';
2
2
  import type { MemoryLogger } from '../types.js';
3
3
  import type { EmbeddingProviderInfo, EmbeddingService } from './embedding.js';
4
- import type { L0Store } from './l0.js';
5
4
  import type { L1Store } from './l1.js';
6
5
  import { LocalEmbeddingService } from './local-embedding.js';
7
6
  import { ModelDownloadQueue } from './download-queue.js';
@@ -74,7 +73,6 @@ export interface EmbeddingManagerDeps {
74
73
  * 否则设置页里的编辑即时生效不到活切换。 */
75
74
  getEff?: () => MemoryConfig;
76
75
  db: MemoryDb;
77
- l0: L0Store;
78
76
  l1: L1Store;
79
77
  sourceStore: EmbeddingSourceStore;
80
78
  installer: RuntimeInstaller;
@@ -155,7 +153,7 @@ export declare class EmbeddingManager {
155
153
  * 门槛全部前置,且**宁可拒绝也不谎报**:以下四种情况直接抛错而非"成功受理":
156
154
  *
157
155
  * - 插件已卸载 / 重嵌已在跑 / 嵌入源切换占着锁:并发语义,重复触发无意义;
158
- * - 嵌入服务未就绪:`L1Store.reindex` / `L0Store.reindex` 会**静默短路**成
156
+ * - 嵌入服务未就绪:`L1Store.reindex` 会**静默短路**成
159
157
  * `0/0/0`,受理了就等于告诉用户"重建成功、零条待补"——而真相是它根本没开始。
160
158
  *
161
159
  * 用抛错而不是返回 `{accepted:false, error}`:与 `embedding-model-delete` 等既有
@@ -8,7 +8,7 @@
8
8
  * embedding.allowLocalModels 上限约束;
9
9
  * - 活切换链(后台执行,RPC 立即返回 accepted,进度靠轮询):
10
10
  * 安装运行时(首次本地)→ 预热加载 → 换服务 + swapProvider(维度变化 drop 向量表)
11
- * → 后台全量重嵌(L1/L0 计数进度,可取消)→ 持久化状态。
11
+ * → 后台全量重嵌(L1 计数进度,可取消;l0 计数进度字段随契约保留恒 0)→ 持久化状态。
12
12
  * 失败语义:异常(安装失败/模型加载失败/db 拒绝)→ 状态不持久化,重启回到旧源;
13
13
  * 重嵌取消/部分失败 → 已切换(物理表即新维度,meta 已同步),缺失向量由周期
14
14
  * backfill 补齐——不回滚(回滚需要再 drop 一次表,得不偿失)。
@@ -330,7 +330,7 @@ export class EmbeddingManager {
330
330
  * 门槛全部前置,且**宁可拒绝也不谎报**:以下四种情况直接抛错而非"成功受理":
331
331
  *
332
332
  * - 插件已卸载 / 重嵌已在跑 / 嵌入源切换占着锁:并发语义,重复触发无意义;
333
- * - 嵌入服务未就绪:`L1Store.reindex` / `L0Store.reindex` 会**静默短路**成
333
+ * - 嵌入服务未就绪:`L1Store.reindex` 会**静默短路**成
334
334
  * `0/0/0`,受理了就等于告诉用户"重建成功、零条待补"——而真相是它根本没开始。
335
335
  *
336
336
  * 用抛错而不是返回 `{accepted:false, error}`:与 `embedding-model-delete` 等既有
@@ -345,7 +345,7 @@ export class EmbeddingManager {
345
345
  throw new Error('嵌入源切换进行中,请稍后再试');
346
346
  if (!this.currentInfo)
347
347
  throw new Error('嵌入源已关闭,重建无意义(请先启用嵌入)');
348
- if (!this.deps.l1.vectorsReady() || !this.deps.l0.vectorsReady()) {
348
+ if (!this.deps.l1.vectorsReady()) {
349
349
  throw new Error('嵌入服务未就绪(模型加载中或推理运行时未安装),请稍后再试');
350
350
  }
351
351
  void this.reindexNow()
@@ -435,7 +435,6 @@ export class EmbeddingManager {
435
435
  const oldLocal = this.localSvc;
436
436
  this.localSvc = next.source === 'local' ? svc : null;
437
437
  this.current = svc;
438
- this.deps.l0.setEmbeddingService(svc);
439
438
  this.deps.l1.setEmbeddingService(svc);
440
439
  oldLocal?.close();
441
440
  let pendingNote = '';
@@ -510,15 +509,9 @@ export class EmbeddingManager {
510
509
  shouldCancel: () => this.reindexCancel,
511
510
  });
512
511
  failedTotal += r1.failed;
513
- const r0 = await this.deps.l0.reindex({
514
- onProgress: (done, total) => {
515
- this.reindex.l0Done = done;
516
- this.reindex.l0Total = total;
517
- },
518
- shouldCancel: () => this.reindexCancel,
519
- });
520
- failedTotal += r0.failed;
521
- const cancelled = !!(r1.cancelled || r0.cancelled);
512
+ // T8.4:l0 向量腿随写停退役(表清空后无缺失,无需重建);契约的
513
+ // l0Done/l0Total 字段保留恒 0,client 进度面零改动。
514
+ const cancelled = !!r1.cancelled;
522
515
  this.reindex.cancelled = cancelled;
523
516
  this.reindex.running = false;
524
517
  this.invalidateVecCache(); // 收尾:计数必须立刻反映重建结果(TTL 可能还压着 1s 旧值)
@@ -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 列表(诊断/面板用;未就绪返回空)。 */