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

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
@@ -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 时按**调用方
@@ -172,7 +197,10 @@ export async function apply(ctx, config) {
172
197
  }
173
198
  const stores = {
174
199
  usage: undefined, // 装配于下方(依赖 dataDir 就绪)
175
- l0: new L0Store(dataDir, db, embed, logger),
200
+ // DRSC 客户端(T8.4 读切):蒸馏背景窗 + conversation_search 工具面的历史供给。
201
+ // 硬依赖无本地回退(l0 表退役);trustedHosts 惰性读取(webStartup 可能晚到)。
202
+ drsc: new DrscClient({ trustedHosts: () => readStartupTrustedHosts(ctx) }),
203
+ l0: new L0Store(db),
176
204
  l1: new L1Store(dataDir, db, embed, config.recall.strategy, logger, config.recall.decayHalfLifeDays,
177
205
  // §D 第 3 路:图谱回链。图谱不可用时 searchNodes 自带 no-op,不影响双路。
178
206
  (query, limit, family) => db.graphStore.searchNodes(query, limit, family ? [family] : undefined),
@@ -210,7 +238,6 @@ export async function apply(ctx, config) {
210
238
  if (storageOk) {
211
239
  try {
212
240
  await Promise.all([
213
- stores.l0.init(),
214
241
  stores.l1.init(),
215
242
  stores.scenes.chat.init(),
216
243
  stores.scenes.work.init(),
@@ -255,18 +282,18 @@ export async function apply(ctx, config) {
255
282
  // 增量:配置变化路径向量表已被 drop,全部记录均"缺失",等效全量;
256
283
  // 活切换发起时让路(swap 会 drop 表,并发跑只是白烧嵌入调用)
257
284
  const shouldCancel = () => disposed || !!embedManagerRef?.isBusy();
285
+ // T8.4:l0 向量腿随写停退役(表清空后无缺失,无需重建)
258
286
  const r1 = await stores.l1.reindex({ shouldCancel });
259
- const r0 = await stores.l0.reindex({ shouldCancel });
260
287
  // missing 复查:嵌入服务未就绪时 reindex 短路 0/0/0——那是"没跑"不是"成功",
261
288
  // 不得标记 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) {
289
+ const missingAfter = db.countL1VecMissing(db.getVecSkipSet('l1'));
290
+ if (r1.failed === 0 && missingAfter === 0) {
264
291
  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 ?? '配置变化'})`);
292
+ const skipNote = r1.skipped > 0 ? `,跳过不可嵌入 ${r1.skipped} 条` : '';
293
+ logger.info(`[memory] 向量重建完成:L1 ${r1.written} 条${skipNote}(原因:${dbInit.reason ?? '配置变化'})`);
267
294
  }
268
295
  else {
269
- logger.warn(`[memory] 向量重建未完成(L1 失败 ${r1.failed},L0 失败 ${r0.failed},剩缺 ${Math.max(missingAfter, 0)} 条),下次启动/补齐周期重试`);
296
+ logger.warn(`[memory] 向量重建未完成(L1 失败 ${r1.failed},剩缺 ${Math.max(missingAfter, 0)} 条),下次启动/补齐周期重试`);
270
297
  }
271
298
  }
272
299
  catch (err) {
@@ -302,19 +329,18 @@ export async function apply(ctx, config) {
302
329
  if (embedManagerRef?.isBusy())
303
330
  return;
304
331
  const l1Missing = db.countL1VecMissing(db.getVecSkipSet('l1')) > 0;
305
- const l0Missing = db.countL0VecMissing(db.getVecSkipSet('l0')) > 0;
306
- if (!l1Missing && !l0Missing)
332
+ if (!l1Missing)
307
333
  return;
308
334
  const shouldCancel = () => disposed || !!embedManagerRef?.isBusy();
335
+ // T8.4:l0 向量腿随写停退役
309
336
  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) {
337
+ const missingAfter = db.countL1VecMissing(db.getVecSkipSet('l1'));
338
+ if (r1.failed === 0 && missingAfter === 0) {
313
339
  db.markEmbeddingSynced(infoNow);
314
- logger.info(`[memory] 向量补齐完成:L1 ${r1.written} 条,L0 ${r0.written} 条(跳过不可嵌入 ${r1.skipped + r0.skipped} 条)`);
340
+ logger.info(`[memory] 向量补齐完成:L1 ${r1.written} 条(跳过不可嵌入 ${r1.skipped} 条)`);
315
341
  }
316
- else if (r1.failed > 0 || r0.failed > 0) {
317
- logger.warn(`[memory] 向量补齐未完成(L1 失败 ${r1.failed},L0 失败 ${r0.failed}),下个周期重试`);
342
+ else if (r1.failed > 0) {
343
+ logger.warn(`[memory] 向量补齐未完成(L1 失败 ${r1.failed}),下个周期重试`);
318
344
  }
319
345
  }
320
346
  catch (err) {
@@ -342,7 +368,6 @@ export async function apply(ctx, config) {
342
368
  dataDir,
343
369
  cfg: config,
344
370
  db,
345
- l0: stores.l0,
346
371
  l1: stores.l1,
347
372
  sourceStore,
348
373
  installer,
@@ -388,7 +413,7 @@ export async function apply(ctx, config) {
388
413
  : undefined;
389
414
  let flushL0;
390
415
  if (storageOk) {
391
- flushL0 = registerCapture(ctx, config, runner, stores.l0, logger, live, modes);
416
+ flushL0 = registerCapture(ctx, config, runner, db.l0Watermark, logger, live, modes);
392
417
  }
393
418
  // 召回使用统计(进 topN 才算 used;热路径只碰内存,去抖落盘)
394
419
  const recallUsage = new RecallUsageStore(dataDir, logger);
@@ -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,收了它
@@ -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 旧值)
@@ -0,0 +1,46 @@
1
+ /**
2
+ * L0 水位表(T8.4 l0 写停,drsc-redact-memory-wiring task_16)。
3
+ *
4
+ * capture 停写 l0_conversations/l0_fts/l0_vec 三表后,§A 崩溃恢复的幂等判据
5
+ * (maxCapturedTurn / hasAnyL0Message)失去数据源——本小表接住:每会话一行,
6
+ * 只存 (max_turn, last_step),几十 KB 量级。正文改由 DRSC 投影供读(历史面),
7
+ * 水位只服务「重启不重放旧 turn」的自检,不再承载任何文本。
8
+ *
9
+ * 语义对齐被退役的 SQL 口径:
10
+ * - `maxTurn(sid)` ≡ 退役前 `MAX(turn)`;只前进不回退(重放/乱序登记无害);
11
+ * - `hasAny(sid, t)` ≡ 退役前 `t <= MAX(turn)` 的存在性判定——恢复窗口
12
+ * (foldRecoverableTurns 只产出 turn > 水位 的括号)内恒 false,行为与
13
+ * MAX 口径等价;写停后正文无「部分写残行」,逐 turn 点查的原始动机消失。
14
+ *
15
+ * 基线物化:写停生效前已存在的 l0_conversations 行由 {@link ensureBaseline}
16
+ * 一次性灌入(INSERT OR IGNORE,存量水位行不覆盖)——没有它,升级后的首次
17
+ * resume 会把水位当空、重放旧 turn 重复蒸馏。每次 init 幂等重跑(空表扫描
18
+ * 零成本;task_22 存量清退删表后同样安静)。
19
+ *
20
+ * 与 CostLedger/GraphStore 同款纪律:独立职责类,唯一耦合是共享 node:sqlite
21
+ * 连接;未 init / 降级时全部方法安全 no-op。
22
+ */
23
+ import type { DatabaseSync } from 'node:sqlite';
24
+ import type { MemoryLogger } from '../types.js';
25
+ export declare const L0_WATERMARK_DDL = "\nCREATE TABLE IF NOT EXISTS l0_watermark (\n session_id TEXT PRIMARY KEY,\n max_turn INTEGER NOT NULL DEFAULT 0,\n last_step INTEGER,\n updated_at TEXT DEFAULT (datetime('now'))\n)\n";
26
+ export declare class L0WatermarkStore {
27
+ private db;
28
+ private logger;
29
+ init(db: DatabaseSync, logger?: MemoryLogger): void;
30
+ close(): void;
31
+ /**
32
+ * 基线物化:从现存 l0_conversations 把每会话最大 turn 灌进水位表。
33
+ * INSERT OR IGNORE = 已有水位行(活捕获已推进)一律不覆盖;旧行文与
34
+ * 水位并存期两侧取大者由 record 的单调语义兜底。
35
+ */
36
+ ensureBaseline(): void;
37
+ /**
38
+ * 登记一个已捕获轮次(写停后 capture 的唯一落盘点)。单调:turn 更小/相等的
39
+ * 登记不回退水位(恢复对账与乱序事件都安全);step 取该轮已知最大 step。
40
+ */
41
+ record(sessionId: string, turn: number, step?: number): void;
42
+ /** §A 水位线:该会话已捕获的最大 turn(空会话/未 init 返回 undefined)。 */
43
+ maxTurn(sessionId: string): number | undefined;
44
+ /** §A 幂等检查:该 (session, turn) 是否已捕获(≡ 退役前 hasAnyL0Message)。 */
45
+ hasAny(sessionId: string, turn: number): boolean;
46
+ }
@@ -0,0 +1,93 @@
1
+ export const L0_WATERMARK_DDL = `
2
+ CREATE TABLE IF NOT EXISTS l0_watermark (
3
+ session_id TEXT PRIMARY KEY,
4
+ max_turn INTEGER NOT NULL DEFAULT 0,
5
+ last_step INTEGER,
6
+ updated_at TEXT DEFAULT (datetime('now'))
7
+ )
8
+ `;
9
+ export class L0WatermarkStore {
10
+ db = null;
11
+ logger;
12
+ init(db, logger) {
13
+ this.db = db;
14
+ this.logger = logger;
15
+ db.exec(L0_WATERMARK_DDL);
16
+ this.ensureBaseline();
17
+ }
18
+ close() {
19
+ this.db = null;
20
+ }
21
+ /**
22
+ * 基线物化:从现存 l0_conversations 把每会话最大 turn 灌进水位表。
23
+ * INSERT OR IGNORE = 已有水位行(活捕获已推进)一律不覆盖;旧行文与
24
+ * 水位并存期两侧取大者由 record 的单调语义兜底。
25
+ */
26
+ ensureBaseline() {
27
+ if (!this.db)
28
+ return;
29
+ try {
30
+ this.db.exec(`
31
+ INSERT OR IGNORE INTO l0_watermark (session_id, max_turn, last_step)
32
+ SELECT session_id, MAX(turn), NULL
33
+ FROM l0_conversations
34
+ WHERE turn IS NOT NULL
35
+ GROUP BY session_id
36
+ `);
37
+ }
38
+ catch (err) {
39
+ // l0_conversations 可能已被存量清退(task_22)删除:表缺 = 无基线可灌,安静跳过
40
+ this.logger?.debug?.(`[memory] L0 水位基线物化跳过: ${err instanceof Error ? err.message : String(err)}`);
41
+ }
42
+ }
43
+ /**
44
+ * 登记一个已捕获轮次(写停后 capture 的唯一落盘点)。单调:turn 更小/相等的
45
+ * 登记不回退水位(恢复对账与乱序事件都安全);step 取该轮已知最大 step。
46
+ */
47
+ record(sessionId, turn, step) {
48
+ if (!this.db || !sessionId || !Number.isFinite(turn))
49
+ return;
50
+ try {
51
+ this.db
52
+ .prepare(`INSERT INTO l0_watermark (session_id, max_turn, last_step, updated_at)
53
+ VALUES (?, ?, ?, datetime('now'))
54
+ ON CONFLICT(session_id) DO UPDATE SET
55
+ max_turn = MAX(l0_watermark.max_turn, excluded.max_turn),
56
+ last_step = CASE WHEN excluded.max_turn > l0_watermark.max_turn
57
+ THEN excluded.last_step ELSE l0_watermark.last_step END,
58
+ updated_at = excluded.updated_at`)
59
+ .run(sessionId, turn, typeof step === 'number' && Number.isFinite(step) ? step : null);
60
+ }
61
+ catch (err) {
62
+ this.logger?.warn(`[memory] L0 水位登记失败(幂等自检可能重放): ${err instanceof Error ? err.message : String(err)}`);
63
+ }
64
+ }
65
+ /** §A 水位线:该会话已捕获的最大 turn(空会话/未 init 返回 undefined)。 */
66
+ maxTurn(sessionId) {
67
+ if (!this.db || !sessionId)
68
+ return undefined;
69
+ try {
70
+ const row = this.db
71
+ .prepare('SELECT max_turn FROM l0_watermark WHERE session_id = ?')
72
+ .get(sessionId);
73
+ return typeof row?.max_turn === 'number' ? row.max_turn : undefined;
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ }
79
+ /** §A 幂等检查:该 (session, turn) 是否已捕获(≡ 退役前 hasAnyL0Message)。 */
80
+ hasAny(sessionId, turn) {
81
+ if (!this.db || !sessionId || !Number.isFinite(turn))
82
+ return false;
83
+ try {
84
+ const row = this.db
85
+ .prepare('SELECT 1 FROM l0_watermark WHERE session_id = ? AND max_turn >= ? LIMIT 1')
86
+ .get(sessionId, turn);
87
+ return row !== undefined;
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
93
+ }
@@ -1,48 +1,18 @@
1
- import type { ConversationMessage, L0MessageRecord, MemoryLogger } from '../types.js';
2
- import { type EmbeddingService } from './embedding.js';
3
- import { type MemoryDb } from './sqlite.js';
1
+ /**
2
+ * L0 存量计数门面(T8.4 l0 写路径退役后)。
3
+ *
4
+ * capture 已停写 l0 正文三表与 JSONL 事实源(见 hooks/capture.ts 与
5
+ * store/l0-watermark.ts),历史读面(recentBySession / search)改经
6
+ * `clients/drsc.ts` 的 DrscClient(硬依赖,无本地回退)——本类只剩状态
7
+ * 面板的两个只读计数(今日捕获 / 按会话累计)。task_22 存量清退后表为
8
+ * 空壳,计数自然归零,读路径无需判空。
9
+ */
10
+ import type { MemoryDb } from './sqlite.js';
4
11
  export declare class L0Store {
5
12
  private readonly db;
6
- private readonly dir;
7
- private readonly legacyDir;
8
- private readonly helper;
9
- private embedSvc;
10
- private readonly logger?;
11
- constructor(dataDir: string, db: MemoryDb, embed?: EmbeddingService, logger?: MemoryLogger);
12
- init(): Promise<void>;
13
- /** 旧版 l0/*.jsonl 一次性导入检索库,成功后目录改名 l0.imported/。 */
14
- private importLegacy;
15
- append(sessionId: string, messages: ConversationMessage[]): Promise<void>;
16
- /** 今日已捕获消息数(SQL 计数,不再读整文件)。 */
13
+ constructor(db: MemoryDb);
14
+ /** 今日已捕获消息数(SQL 计数,不再读整文件;写停后归零,面板口径保留)。 */
17
15
  countToday(): Promise<number>;
18
16
  /** 该会话累计已捕获消息数(session-stats 数据源;索引 COUNT)。 */
19
17
  countBySession(sessionId: string): Promise<number>;
20
- /** 该会话最近 n 条消息(时间升序;蒸馏背景参考用,按会话现查——ADR-0003)。 */
21
- recentBySession(sessionId: string, limit: number): Promise<ConversationMessage[]>;
22
- /** §A 水位线:该会话 L0 已落盘的最大 turn(空会话 undefined;委托 MemoryDb)。 */
23
- maxCapturedTurn(sessionId: string): number | undefined;
24
- /** §A 幂等检查:该 (session, turn) 是否已有任意 L0 行(委托 MemoryDb)。 */
25
- hasAnyL0Message(sessionId: string, turn: number): boolean;
26
- /** 检索:FTS + 向量 hybrid(RRF 融合),返回按相关性排序的消息。 */
27
- search(query: string, limit: number): Promise<L0MessageRecord[]>;
28
- /** 活切换嵌入源:同步换底层服务(嵌入源三态切换用)。 */
29
- setEmbeddingService(svc: EmbeddingService): void;
30
- /** 向量写入能力是否就绪。`reindex` 在未就绪时**静默短路**成 0/0/0
31
- * (见本文件 `reindex` 首行),调用方必须自己问这里——否则"根本没跑"
32
- * 会长得和"跑完了、零条待补"一模一样。 */
33
- vectorsReady(): boolean;
34
- /**
35
- * 增量重嵌入(同 L1Store.reindex:只补缺失向量,零向量记 skipped 并入 skip 集,
36
- * 不算失败、不阻塞同步标记——保证补齐判据收敛)。onProgress/shouldCancel
37
- * 供活切换的进度展示与取消。
38
- */
39
- reindex(opts?: {
40
- onProgress?: (done: number, total: number) => void;
41
- shouldCancel?: () => boolean;
42
- }): Promise<{
43
- written: number;
44
- failed: number;
45
- skipped: number;
46
- cancelled?: boolean;
47
- }>;
48
18
  }