dsh-prime-memory 0.26.3 → 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.
@@ -0,0 +1,93 @@
1
+ /**
2
+ * DrscClient(T8.4 读切,drsc-redact-memory-wiring task_18):memory 读历史
3
+ * 面的 dsh-reference-session-collector HTTP 客户端。
4
+ *
5
+ * 两个读点(R7):
6
+ * - 蒸馏背景窗(recentBySession 等价供给)→ `op=session&tail=N&redact=1`
7
+ * (collector task_11 尾窗升序 + task_12 redact 臂);
8
+ * - 工具面 conversation_search → `op=search&redact=1`(命中基于原文 fts,
9
+ * 返回 snippet 取脱敏锚定文本)。
10
+ *
11
+ * 硬依赖纪律(对齐 T7.3 裁定):collector 缺席/超时/非 2xx/ok=false 一律抛
12
+ * {@link DrscClientError}——**绝不回退本地 l0**(表已退役,无退路);调用方
13
+ * 分口径处理:蒸馏背景窗空窗降级(runner),工具面显式报错(tools)。
14
+ *
15
+ * 同源 Host 解析(steward drs-source 的 resolveDrsBaseUrl 同款):
16
+ * 显式 baseUrl > 首个 trustedHosts 条目(补 http://)> `http://127.0.0.1:8080`
17
+ * (宿主缺省口)。Host 头回显首个 trustedHosts 条目:collector 信任门是精确
18
+ * 匹配,条目省略端口而 URL 带端口会失配。trustedHosts 惰性读取(webStartup
19
+ * 可能晚于本插件就绪),每次请求现算,无缓存陈旧面。
20
+ */
21
+ import type { ConversationMessage } from '../types.js';
22
+ /** collector API 路径(与其 DRS_API_PATH 字面一致;不跨包 import)。 */
23
+ export declare const DRSC_API_PATH = "/api/dsh-reference-session-collector";
24
+ /** 客户端缺省超时:loopback 正常 <10ms,5s 是给冷启动 GC/断点的天花板。 */
25
+ export declare const DRSC_DEFAULT_TIMEOUT_MS = 5000;
26
+ /** collector 缺席/超时/契约破损。显式报错,不回退本地。 */
27
+ export declare class DrscClientError extends Error {
28
+ constructor(message: string, options?: {
29
+ cause?: unknown;
30
+ });
31
+ }
32
+ /**
33
+ * 基址解析:显式配置 > 首个 trustedHosts 条目(补 http:// 前缀)>
34
+ * `http://127.0.0.1:8080`(宿主默认口;trustedHosts 缺席的老线 collector 门本就放行)。
35
+ */
36
+ export declare function resolveDrscBaseUrl(explicit: string | undefined, trustedHosts?: readonly string[]): string;
37
+ /** `op=session` 的一个 segment 行(redact=1 时 text 为脱敏正文;wire 蛇形直取)。 */
38
+ export interface DrscSessionRow {
39
+ seq: number;
40
+ turn: number;
41
+ step: number;
42
+ kind: string;
43
+ role: string;
44
+ time: number;
45
+ text: string;
46
+ }
47
+ /** `op=search` 的一个命中(redact=1 时 snippet 为脱敏锚定文本)。 */
48
+ export interface DrscSearchHit {
49
+ session_id: string;
50
+ seq: number;
51
+ kind: string;
52
+ rank: number;
53
+ snippet: string;
54
+ time: number;
55
+ on_surface: number;
56
+ turn: number;
57
+ }
58
+ /** kind → L0 角色词表(memory 只收 user/assistant 两类,其余(tool/* 等)不映射)。 */
59
+ export declare function drscRoleOf(kind: string): 'user' | 'assistant' | undefined;
60
+ /**
61
+ * session 行 → ConversationMessage 形状映射(蒸馏背景窗用)。
62
+ * - id:确定性合成 `drsc:<session>:<seq>`(跨次调用稳定,anchor 追溯可解释);
63
+ * - anchor:turn>0 才成立(与 ConversationAnchor 红线一致:没坐标不伪造);
64
+ * - 非 user/assistant 段(tool/call 等)返回 undefined,调用方跳过。
65
+ */
66
+ export declare function drscRowToConversationMessage(row: DrscSessionRow, sessionId: string): ConversationMessage | undefined;
67
+ export interface DrscClientOptions {
68
+ /** 显式基址(缺省走 trustedHosts → 127.0.0.1:8080,见 resolveDrscBaseUrl)。 */
69
+ baseUrl?: string;
70
+ /** trustedHosts 惰性读取(webStartup 服务可能晚于装配;每次请求现取)。 */
71
+ trustedHosts?: () => readonly string[] | undefined;
72
+ /** 注入面(单测);缺省全局 fetch。 */
73
+ fetchImpl?: typeof fetch;
74
+ /** 单请求超时 ms;缺省 {@link DRSC_DEFAULT_TIMEOUT_MS}。 */
75
+ timeoutMs?: number;
76
+ }
77
+ export declare class DrscClient {
78
+ private readonly options;
79
+ private readonly fetchImpl;
80
+ private readonly timeoutMs;
81
+ constructor(options?: DrscClientOptions);
82
+ /** 每次请求现算基址与 Host 回显(trustedHosts 惰性;无缓存陈旧面)。 */
83
+ private base;
84
+ /** 一个 GET 的 JSON 包络(非 2xx / ok!==true / 超时 / 断网一律抛 DrscClientError)。 */
85
+ private getJson;
86
+ /**
87
+ * 会话尾窗:`op=session&session=<id>&tail=N&redact=1` → 脱敏消息行(升序)。
88
+ * collector 无此会话(session:null)返回空数组(新会话首轮的常态,非错误)。
89
+ */
90
+ sessionTail(sessionId: string, tail: number): Promise<DrscSessionRow[]>;
91
+ /** 原史检索:`op=search&q=<query>&limit=N&redact=1` → 脱敏 snippet 命中。 */
92
+ search(query: string, limit: number): Promise<DrscSearchHit[]>;
93
+ }
@@ -0,0 +1,139 @@
1
+ /** collector API 路径(与其 DRS_API_PATH 字面一致;不跨包 import)。 */
2
+ export const DRSC_API_PATH = '/api/dsh-reference-session-collector';
3
+ /** 客户端缺省超时:loopback 正常 <10ms,5s 是给冷启动 GC/断点的天花板。 */
4
+ export const DRSC_DEFAULT_TIMEOUT_MS = 5_000;
5
+ /** collector 缺席/超时/契约破损。显式报错,不回退本地。 */
6
+ export class DrscClientError extends Error {
7
+ constructor(message, options) {
8
+ super(message, options);
9
+ this.name = 'DrscClientError';
10
+ }
11
+ }
12
+ /**
13
+ * 基址解析:显式配置 > 首个 trustedHosts 条目(补 http:// 前缀)>
14
+ * `http://127.0.0.1:8080`(宿主默认口;trustedHosts 缺席的老线 collector 门本就放行)。
15
+ */
16
+ export function resolveDrscBaseUrl(explicit, trustedHosts) {
17
+ const trimmed = explicit?.trim() ?? '';
18
+ const pick = trimmed !== '' ? trimmed : trustedHosts?.find((entry) => entry.trim() !== '')?.trim();
19
+ if (pick === undefined || pick === '')
20
+ return 'http://127.0.0.1:8080';
21
+ return /^https?:\/\//u.test(pick) ? pick : `http://${pick}`;
22
+ }
23
+ /** kind → L0 角色词表(memory 只收 user/assistant 两类,其余(tool/* 等)不映射)。 */
24
+ export function drscRoleOf(kind) {
25
+ if (kind === 'user/message')
26
+ return 'user';
27
+ if (kind === 'assistant/message')
28
+ return 'assistant';
29
+ return undefined;
30
+ }
31
+ /**
32
+ * session 行 → ConversationMessage 形状映射(蒸馏背景窗用)。
33
+ * - id:确定性合成 `drsc:<session>:<seq>`(跨次调用稳定,anchor 追溯可解释);
34
+ * - anchor:turn>0 才成立(与 ConversationAnchor 红线一致:没坐标不伪造);
35
+ * - 非 user/assistant 段(tool/call 等)返回 undefined,调用方跳过。
36
+ */
37
+ export function drscRowToConversationMessage(row, sessionId) {
38
+ const role = drscRoleOf(row.kind);
39
+ if (role === undefined)
40
+ return undefined;
41
+ const msg = {
42
+ id: `drsc:${sessionId}:${row.seq}`,
43
+ role,
44
+ content: row.text,
45
+ timestamp: row.time,
46
+ };
47
+ if (Number.isFinite(row.turn) && row.turn > 0) {
48
+ msg.anchor = { sessionId, turn: row.turn };
49
+ if (Number.isFinite(row.step) && row.step > 0)
50
+ msg.anchor.step = row.step;
51
+ }
52
+ return msg;
53
+ }
54
+ export class DrscClient {
55
+ options;
56
+ fetchImpl;
57
+ timeoutMs;
58
+ constructor(options = {}) {
59
+ this.options = options;
60
+ this.fetchImpl = options.fetchImpl ?? fetch;
61
+ this.timeoutMs = options.timeoutMs ?? DRSC_DEFAULT_TIMEOUT_MS;
62
+ }
63
+ /** 每次请求现算基址与 Host 回显(trustedHosts 惰性;无缓存陈旧面)。 */
64
+ base() {
65
+ const trusted = this.options.trustedHosts?.() ?? [];
66
+ const base = resolveDrscBaseUrl(this.options.baseUrl, trusted).replace(/\/+$/u, '');
67
+ const first = trusted.find((entry) => entry.trim() !== '')?.trim();
68
+ return { url: `${base}${DRSC_API_PATH}`, ...(first !== undefined ? { hostHeader: first } : {}) };
69
+ }
70
+ /** 一个 GET 的 JSON 包络(非 2xx / ok!==true / 超时 / 断网一律抛 DrscClientError)。 */
71
+ async getJson(query) {
72
+ const { url, hostHeader } = this.base();
73
+ let response;
74
+ try {
75
+ response = await this.fetchImpl(`${url}${query}`, {
76
+ headers: {
77
+ accept: 'application/json',
78
+ ...(hostHeader === undefined ? {} : { host: hostHeader }),
79
+ },
80
+ signal: AbortSignal.timeout(this.timeoutMs),
81
+ });
82
+ }
83
+ catch (err) {
84
+ throw new DrscClientError(`collector 不可达(${query}): ${err instanceof Error ? err.message : String(err)}`, {
85
+ cause: err,
86
+ });
87
+ }
88
+ if (!response.ok)
89
+ throw new DrscClientError(`collector HTTP ${response.status}(${query})`);
90
+ let body;
91
+ try {
92
+ body = await response.json();
93
+ }
94
+ catch (err) {
95
+ throw new DrscClientError(`collector 响应非 JSON(${query})`, { cause: err });
96
+ }
97
+ if (typeof body !== 'object' || body === null || body.ok !== true) {
98
+ const reason = typeof body === 'object' && body !== null
99
+ ? String(body.error ?? 'ok=false')
100
+ : '响应非对象';
101
+ throw new DrscClientError(`collector 包络异常(${query}): ${reason}`);
102
+ }
103
+ return body;
104
+ }
105
+ /**
106
+ * 会话尾窗:`op=session&session=<id>&tail=N&redact=1` → 脱敏消息行(升序)。
107
+ * collector 无此会话(session:null)返回空数组(新会话首轮的常态,非错误)。
108
+ */
109
+ async sessionTail(sessionId, tail) {
110
+ const body = await this.getJson(`?op=session&session=${encodeURIComponent(sessionId)}&tail=${Math.max(0, Math.floor(tail))}&redact=1`);
111
+ if (body['session'] === null || body['session'] === undefined)
112
+ return [];
113
+ const rows = Array.isArray(body['rows']) ? body['rows'] : [];
114
+ return rows.map((r) => ({
115
+ seq: Number(r['seq'] ?? 0),
116
+ turn: Number(r['turn'] ?? 0),
117
+ step: Number(r['step'] ?? 0),
118
+ kind: String(r['kind'] ?? ''),
119
+ role: String(r['role'] ?? ''),
120
+ time: Number(r['time'] ?? 0),
121
+ text: String(r['text'] ?? ''),
122
+ }));
123
+ }
124
+ /** 原史检索:`op=search&q=<query>&limit=N&redact=1` → 脱敏 snippet 命中。 */
125
+ async search(query, limit) {
126
+ const body = await this.getJson(`?op=search&q=${encodeURIComponent(query)}&limit=${Math.max(1, Math.floor(limit))}&redact=1`);
127
+ const hits = Array.isArray(body['hits']) ? body['hits'] : [];
128
+ return hits.map((h) => ({
129
+ session_id: String(h['session_id'] ?? ''),
130
+ seq: Number(h['seq'] ?? 0),
131
+ kind: String(h['kind'] ?? ''),
132
+ rank: Number(h['rank'] ?? 0),
133
+ snippet: String(h['snippet'] ?? ''),
134
+ time: Number(h['time'] ?? 0),
135
+ on_surface: Number(h['on_surface'] ?? 1),
136
+ turn: Number(h['turn'] ?? 0),
137
+ }));
138
+ }
139
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * DRS 历史锚点回填(三期接线 T8.2,一次性任务的纯核心)。
3
+ *
4
+ * 职责:`l0_conversations` 里 `turn IS NULL` 的旧行,按
5
+ * (session_id, role, 时间窗) 与 DRS 投影库 segments(turn/step/role/time)
6
+ * 最近邻匹配,补填 turn/step。
7
+ *
8
+ * 纪律(对齐 store/sqlite.ts R7 迁移注释,「旧行留 NULL ⇒ 无锚点,不伪造」):
9
+ * - 匹配不上留 NULL;同窗内出现**多个不同 turn** 的候选 = 歧义,也留 NULL;
10
+ * - UPDATE 恒带 `AND turn IS NULL`,已锚定行永不覆盖(与 upsert COALESCE 同向);
11
+ * - 行数不变式:回填只填列,绝不增删行;apply 报告给前后 COUNT 对账。
12
+ *
13
+ * DRS 来源契约:projection.db segments 只读直连——与 HTTP `op=session&turn=`
14
+ * 的检索面同字段(session_id/turn/step/role/time);离线一次性任务走只读
15
+ * SQLite,宿主不在线也可跑。role 词表两侧同形:DRS eventRole 只产
16
+ * user/assistant,l0 capture 只收 user/assistant(capture.ts L0Record)。
17
+ *
18
+ * 本模块零 cordis/运行时依赖:入参是结构化 SQLite 面(prepare/all/run),
19
+ * CLI(scripts/drs-anchor-backfill.mjs)喂真身,单测喂临时 DatabaseSync。
20
+ */
21
+ /** 结构化 SQLite 面(node:sqlite DatabaseSync 的窄投影,方法双向可赋值)。 */
22
+ export interface PreparedStatementLike {
23
+ all(...params: unknown[]): unknown[];
24
+ run(...params: unknown[]): unknown;
25
+ }
26
+ export interface SqliteLike {
27
+ prepare(sql: string): PreparedStatementLike;
28
+ }
29
+ /** 缺省时间窗:|l0.timestamp − segments.time| ≤ 90s 视为同一条消息。 */
30
+ export declare const DEFAULT_WINDOW_MS = 90000;
31
+ export interface AnchorBackfillOptions {
32
+ /** 只回填该会话;缺省全量。 */
33
+ sessionId?: string;
34
+ /** 本次最多处理的候选行数(防跑飞);缺省不限。 */
35
+ limit?: number;
36
+ /** 匹配时间窗(ms);缺省 {@link DEFAULT_WINDOW_MS}。 */
37
+ windowMs?: number;
38
+ }
39
+ /** 一条可回填的匹配(证据齐全:两侧时间与差值)。 */
40
+ export interface AnchorFillRow {
41
+ recordId: string;
42
+ sessionId: string;
43
+ role: string;
44
+ l0Timestamp: number;
45
+ drsTime: number;
46
+ deltaMs: number;
47
+ turn: number;
48
+ step: number;
49
+ }
50
+ /** 一条被保守放弃的候选(宁缺毋伪造)。 */
51
+ export interface AnchorSkipRow {
52
+ recordId: string;
53
+ sessionId: string;
54
+ reason: 'no-drs-session' | 'no-role-match' | 'out-of-window' | 'ambiguous-turn';
55
+ }
56
+ export interface AnchorBackfillPlan {
57
+ windowMs: number;
58
+ /** 扫到的候选数(turn IS NULL)。 */
59
+ candidates: number;
60
+ /** DRS 侧有投影的会话数。 */
61
+ drsSessions: number;
62
+ /** 已锚定(无需处理)行数——信息面,不是候选。 */
63
+ alreadyAnchored: number;
64
+ fills: AnchorFillRow[];
65
+ skips: AnchorSkipRow[];
66
+ }
67
+ /**
68
+ * 产出回填计划(纯只读,两个库都不写)。匹配规则:
69
+ * 同会话 + 同 role + |Δt| ≤ windowMs 的 DRS 行里,若候选 turn 唯一 → 取
70
+ * |Δt| 最近一条;turn 不唯一 → 歧义放弃;无行/无窗内行 → 如实分型放弃。
71
+ */
72
+ export declare function planAnchorBackfill(input: {
73
+ memory: SqliteLike;
74
+ drs: SqliteLike;
75
+ } & AnchorBackfillOptions): AnchorBackfillPlan;
76
+ export interface AnchorApplyReport {
77
+ plan: AnchorBackfillPlan;
78
+ applied: number;
79
+ totalRowsBefore: number;
80
+ totalRowsAfter: number;
81
+ /** 行数不变式:回填只填列,前后 COUNT 必须相等。 */
82
+ invariantHolds: boolean;
83
+ }
84
+ /**
85
+ * 应用计划:逐条 UPDATE,恒带 `AND turn IS NULL`(已锚定行永不覆盖;
86
+ * plan→apply 之间若有新写入恰好锚定了同一行,该条自然落空,不报错)。
87
+ */
88
+ export declare function applyAnchorBackfill(memory: SqliteLike, plan: AnchorBackfillPlan): AnchorApplyReport;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * DRS 历史锚点回填(三期接线 T8.2,一次性任务的纯核心)。
3
+ *
4
+ * 职责:`l0_conversations` 里 `turn IS NULL` 的旧行,按
5
+ * (session_id, role, 时间窗) 与 DRS 投影库 segments(turn/step/role/time)
6
+ * 最近邻匹配,补填 turn/step。
7
+ *
8
+ * 纪律(对齐 store/sqlite.ts R7 迁移注释,「旧行留 NULL ⇒ 无锚点,不伪造」):
9
+ * - 匹配不上留 NULL;同窗内出现**多个不同 turn** 的候选 = 歧义,也留 NULL;
10
+ * - UPDATE 恒带 `AND turn IS NULL`,已锚定行永不覆盖(与 upsert COALESCE 同向);
11
+ * - 行数不变式:回填只填列,绝不增删行;apply 报告给前后 COUNT 对账。
12
+ *
13
+ * DRS 来源契约:projection.db segments 只读直连——与 HTTP `op=session&turn=`
14
+ * 的检索面同字段(session_id/turn/step/role/time);离线一次性任务走只读
15
+ * SQLite,宿主不在线也可跑。role 词表两侧同形:DRS eventRole 只产
16
+ * user/assistant,l0 capture 只收 user/assistant(capture.ts L0Record)。
17
+ *
18
+ * 本模块零 cordis/运行时依赖:入参是结构化 SQLite 面(prepare/all/run),
19
+ * CLI(scripts/drs-anchor-backfill.mjs)喂真身,单测喂临时 DatabaseSync。
20
+ */
21
+ /** 缺省时间窗:|l0.timestamp − segments.time| ≤ 90s 视为同一条消息。 */
22
+ export const DEFAULT_WINDOW_MS = 90_000;
23
+ /** DRS segments → 锚点行(只收 turn>0 且 role∈{user,assistant} 的行)。 */
24
+ function loadDrsAnchors(drs, sessionId) {
25
+ const sql = sessionId === undefined
26
+ ? 'SELECT session_id, turn, step, role, time FROM segments WHERE turn > 0 AND role IN (?, ?) ORDER BY session_id, time'
27
+ : 'SELECT session_id, turn, step, role, time FROM segments WHERE turn > 0 AND role IN (?, ?) AND session_id = ? ORDER BY session_id, time';
28
+ const rows = drs.prepare(sql).all('user', 'assistant', ...(sessionId === undefined ? [] : [sessionId]));
29
+ const bySession = new Map();
30
+ for (const row of rows) {
31
+ if (typeof row.session_id !== 'string' || typeof row.turn !== 'number' || typeof row.time !== 'number')
32
+ continue;
33
+ const entry = {
34
+ sessionId: row.session_id,
35
+ turn: row.turn,
36
+ step: typeof row.step === 'number' ? row.step : 0,
37
+ role: typeof row.role === 'string' ? row.role : '',
38
+ time: row.time,
39
+ };
40
+ const bucket = bySession.get(entry.sessionId);
41
+ if (bucket === undefined)
42
+ bySession.set(entry.sessionId, [entry]);
43
+ else
44
+ bucket.push(entry);
45
+ }
46
+ return bySession;
47
+ }
48
+ function loadCandidates(memory, sessionId, limit) {
49
+ const sql = sessionId === undefined
50
+ ? 'SELECT record_id, session_id, role, timestamp FROM l0_conversations WHERE turn IS NULL AND role IN (?, ?) ORDER BY session_id, timestamp'
51
+ : 'SELECT record_id, session_id, role, timestamp FROM l0_conversations WHERE turn IS NULL AND role IN (?, ?) AND session_id = ? ORDER BY session_id, timestamp';
52
+ const rows = memory.prepare(sql).all('user', 'assistant', ...(sessionId === undefined ? [] : [sessionId]));
53
+ const out = [];
54
+ for (const row of rows) {
55
+ if (typeof row.record_id !== 'string' || typeof row.session_id !== 'string' || typeof row.timestamp !== 'number')
56
+ continue;
57
+ out.push({
58
+ recordId: row.record_id,
59
+ sessionId: row.session_id,
60
+ role: typeof row.role === 'string' ? row.role : '',
61
+ timestamp: row.timestamp,
62
+ });
63
+ if (limit !== undefined && out.length >= limit)
64
+ break;
65
+ }
66
+ return out;
67
+ }
68
+ /**
69
+ * 产出回填计划(纯只读,两个库都不写)。匹配规则:
70
+ * 同会话 + 同 role + |Δt| ≤ windowMs 的 DRS 行里,若候选 turn 唯一 → 取
71
+ * |Δt| 最近一条;turn 不唯一 → 歧义放弃;无行/无窗内行 → 如实分型放弃。
72
+ */
73
+ export function planAnchorBackfill(input) {
74
+ const windowMs = input.windowMs ?? DEFAULT_WINDOW_MS;
75
+ const drsBySession = loadDrsAnchors(input.drs, input.sessionId);
76
+ const candidates = loadCandidates(input.memory, input.sessionId, input.limit);
77
+ const anchored = input.memory.prepare(input.sessionId === undefined
78
+ ? 'SELECT COUNT(*) AS n FROM l0_conversations WHERE turn IS NOT NULL'
79
+ : 'SELECT COUNT(*) AS n FROM l0_conversations WHERE turn IS NOT NULL AND session_id = ?').all(...(input.sessionId === undefined ? [] : [input.sessionId]));
80
+ const plan = {
81
+ windowMs,
82
+ candidates: candidates.length,
83
+ drsSessions: drsBySession.size,
84
+ alreadyAnchored: typeof anchored[0]?.n === 'number' ? anchored[0].n : 0,
85
+ fills: [],
86
+ skips: [],
87
+ };
88
+ for (const candidate of candidates) {
89
+ const rows = drsBySession.get(candidate.sessionId);
90
+ if (rows === undefined) {
91
+ plan.skips.push({ recordId: candidate.recordId, sessionId: candidate.sessionId, reason: 'no-drs-session' });
92
+ continue;
93
+ }
94
+ const sameRole = rows.filter(row => row.role === candidate.role);
95
+ if (sameRole.length === 0) {
96
+ plan.skips.push({ recordId: candidate.recordId, sessionId: candidate.sessionId, reason: 'no-role-match' });
97
+ continue;
98
+ }
99
+ const inWindow = sameRole
100
+ .map(row => ({ row, delta: Math.abs(row.time - candidate.timestamp) }))
101
+ .filter(entry => entry.delta <= windowMs);
102
+ if (inWindow.length === 0) {
103
+ plan.skips.push({ recordId: candidate.recordId, sessionId: candidate.sessionId, reason: 'out-of-window' });
104
+ continue;
105
+ }
106
+ const turns = new Set(inWindow.map(entry => entry.row.turn));
107
+ if (turns.size > 1) {
108
+ plan.skips.push({ recordId: candidate.recordId, sessionId: candidate.sessionId, reason: 'ambiguous-turn' });
109
+ continue;
110
+ }
111
+ const nearest = inWindow.reduce((best, entry) => (entry.delta < best.delta ? entry : best));
112
+ plan.fills.push({
113
+ recordId: candidate.recordId,
114
+ sessionId: candidate.sessionId,
115
+ role: candidate.role,
116
+ l0Timestamp: candidate.timestamp,
117
+ drsTime: nearest.row.time,
118
+ deltaMs: nearest.delta,
119
+ turn: nearest.row.turn,
120
+ step: nearest.row.step,
121
+ });
122
+ }
123
+ return plan;
124
+ }
125
+ /**
126
+ * 应用计划:逐条 UPDATE,恒带 `AND turn IS NULL`(已锚定行永不覆盖;
127
+ * plan→apply 之间若有新写入恰好锚定了同一行,该条自然落空,不报错)。
128
+ */
129
+ export function applyAnchorBackfill(memory, plan) {
130
+ const countOf = (sql) => {
131
+ const rows = memory.prepare(sql).all();
132
+ return typeof rows[0]?.n === 'number' ? rows[0].n : 0;
133
+ };
134
+ const totalRowsBefore = countOf('SELECT COUNT(*) AS n FROM l0_conversations');
135
+ const update = memory.prepare('UPDATE l0_conversations SET turn = ?, step = ? WHERE record_id = ? AND turn IS NULL');
136
+ let applied = 0;
137
+ for (const fill of plan.fills) {
138
+ const result = update.run(fill.turn, fill.step, fill.recordId);
139
+ if (typeof result?.changes === 'number' && result.changes > 0)
140
+ applied += result.changes;
141
+ }
142
+ const totalRowsAfter = countOf('SELECT COUNT(*) AS n FROM l0_conversations');
143
+ return {
144
+ plan,
145
+ applied,
146
+ totalRowsBefore,
147
+ totalRowsAfter,
148
+ invariantHolds: totalRowsBefore === totalRowsAfter,
149
+ };
150
+ }
@@ -3,7 +3,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session';
3
3
  import type { MemoryConfig } from '../config.js';
4
4
  import type { MemoryRunner } from '../pipeline/runner.js';
5
5
  import type { SessionModeStore } from '../store/session-modes.js';
6
- import type { L0Store } from '../store/l0.js';
6
+ import type { L0WatermarkStore } from '../store/l0-watermark.js';
7
7
  import type { LiveSettingsHandle } from '../settings.js';
8
8
  import type { MemoryLogger } from '../types.js';
9
9
  export declare function isCaptureRelevant(type: string): boolean;
@@ -20,10 +20,10 @@ export declare class CaptureBuffers {
20
20
  takeTurn(sid: string, turn: number): SessionEvent[];
21
21
  }
22
22
  /**
23
- * 注册 L0 捕获。返回 L0 串行链的冲刷函数(dispose 序在关库前 await,
24
- * 排队中的 turn 消息先落盘);capture 关闭时返回 undefined。
23
+ * 注册 L0 捕获。返回冲刷函数(dispose 序 await;写停后无排队写,恒立即完成)。
24
+ * capture 关闭时返回 undefined。
25
25
  */
26
- export declare function registerCapture(ctx: Context, cfg: MemoryConfig, runner: MemoryRunner, l0: L0Store, logger: MemoryLogger, live: LiveSettingsHandle, modes: SessionModeStore): (() => Promise<void>) | undefined;
26
+ export declare function registerCapture(ctx: Context, cfg: MemoryConfig, runner: MemoryRunner, watermark: L0WatermarkStore, logger: MemoryLogger, live: LiveSettingsHandle, modes: SessionModeStore): (() => Promise<void>) | undefined;
27
27
  /**
28
28
  * 缓冲上限裁剪(防御性;RELEVANT_TYPES 过滤后基本不可达)。
29
29
  * 铁律:进行中轮次(turn/start 之后、turn/end 之前)的事件绝不裁——
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * L0 捕获 Hook:订阅 session/event,按轮次缓冲 user/assistant 消息,
3
- * turn/end 时清洗并交给 Runner 落盘 + 触发蒸馏。
3
+ * turn/end 时清洗并交给 Runner 蒸馏 + 登记水位(T8.4 写停:正文不落库,
4
+ * 历史读面由 DRSC 投影承接)。
4
5
  *
5
6
  * 冷启动保护:插件激活时间之前的事件不捕获(防止恢复会话时倾倒全部历史)。
6
7
  */
@@ -86,20 +87,34 @@ export class CaptureBuffers {
86
87
  return turnEvents;
87
88
  }
88
89
  }
90
+ /** 该批消息里已知的最大 step(水位 last_step 登记用;无 step 锚点返回 undefined)。 */
91
+ function lastStepOf(messages) {
92
+ let step;
93
+ for (const m of messages) {
94
+ const s = m.anchor?.step;
95
+ if (typeof s === 'number' && (step === undefined || s > step))
96
+ step = s;
97
+ }
98
+ return step;
99
+ }
89
100
  /**
90
- * 注册 L0 捕获。返回 L0 串行链的冲刷函数(dispose 序在关库前 await,
91
- * 排队中的 turn 消息先落盘);capture 关闭时返回 undefined。
101
+ * 注册 L0 捕获。返回冲刷函数(dispose 序 await;写停后无排队写,恒立即完成)。
102
+ * capture 关闭时返回 undefined。
92
103
  */
93
- export function registerCapture(ctx, cfg, runner, l0, logger, live, modes) {
104
+ export function registerCapture(ctx, cfg, runner, watermark, logger, live, modes) {
94
105
  if (!cfg.capture.enabled)
95
106
  return;
96
107
  const startFloor = Date.now();
97
108
  const buffers = new CaptureBuffers();
98
109
  // task_8 一次性提示标记:宿主读日志服务不可得时只提示一次(防每次 resume 刷屏)
99
110
  let warnedRecoveryUnavailable = false;
100
- // L0 即时落盘链:turn/end 立刻写,不被蒸馏队列(慢 LLM 调用)阻塞;
101
- // 串行化保证同轮次顺序与单次写入(进程退出时排队中的 L0 不再依赖蒸馏完成)
102
- let l0Queue = Promise.resolve();
111
+ // T8.4 写停(drsc-redact-memory-wiring R6):l0 正文三表(l0_conversations/l0_fts/
112
+ // l0_vec)与 JSONL 事实源一并退役——历史读面由 DRSC 投影供给,本地只剩水位表
113
+ // 做 §A 恢复幂等自检。蒸馏直传不经 DRSC(capture 单点脱敏照旧)。
114
+ // 水位登记是同步 SQLite 写,事件循环天然串行,无需再排队。
115
+ const settleTurn = (sid, turn, messages) => {
116
+ watermark.record(sid, turn, lastStepOf(messages));
117
+ };
103
118
  ctx.on('session/event', (session, event) => {
104
119
  try {
105
120
  const s = live.get();
@@ -146,12 +161,9 @@ export function registerCapture(ctx, cfg, runner, l0, logger, live, modes) {
146
161
  logger.info(`[memory] L0 捕获 turn=${turn} ${messages.length} 条(${Object.entries(roles)
147
162
  .map(([k, v]) => `${k}=${v}`)
148
163
  .join('/')} ,session=${sid},mode=${mode})`);
149
- // L0 立刻落盘(不等蒸馏)
150
- const n = messages.length;
151
- l0Queue = l0Queue
152
- .then(() => l0.append(sid, messages))
153
- .then(() => logger.info(`[memory] L0 落盘 ${n} 条`))
154
- .catch((err) => logger.warn(`[memory] L0 落盘失败: ${err instanceof Error ? err.message : String(err)}`));
164
+ // 写停:水位登记即"落盘"(正文由 DRSC 投影承接);蒸馏直传照旧
165
+ settleTurn(sid, turn, messages);
166
+ logger.info(`[memory] L0 写停登记 turn=${turn}(正文不落库,历史面经 DRSC)`);
155
167
  runner.enqueue(sid, messages, mode);
156
168
  }
157
169
  }
@@ -199,16 +211,16 @@ export function registerCapture(ctx, cfg, runner, l0, logger, live, modes) {
199
211
  }
200
212
  events = degraded;
201
213
  }
202
- const watermark = l0.maxCapturedTurn(sid);
203
- const brackets = foldRecoverableTurns(events, watermark);
214
+ const watermarkTurn = watermark.maxTurn(sid);
215
+ const brackets = foldRecoverableTurns(events, watermarkTurn);
204
216
  if (brackets.length === 0)
205
217
  return;
206
218
  let recovered = 0;
207
219
  let skipped = 0;
208
220
  let total = 0;
209
221
  for (const bracket of brackets) {
210
- if (l0.hasAnyL0Message(sid, bracket.turn)) {
211
- // 部分写残行:水位线只判"整轮无",存在性检查兜底——整轮跳过,宁少收不重复
222
+ if (watermark.hasAny(sid, bracket.turn)) {
223
+ // 水位表判"该轮已捕获":整轮跳过,宁少收不重复(≡ 退役前行存在性检查)
212
224
  skipped += 1;
213
225
  continue;
214
226
  }
@@ -217,25 +229,20 @@ export function registerCapture(ctx, cfg, runner, l0, logger, live, modes) {
217
229
  continue;
218
230
  recovered += 1;
219
231
  total += messages.length;
220
- const n = messages.length;
221
- l0Queue = l0Queue
222
- .then(() => l0.append(sid, messages))
223
- .then(() => logger.info(`[memory] L0 恢复落盘 ${n} 条`))
224
- .catch((err) => logger.warn(`[memory] L0 恢复落盘失败: ${err instanceof Error ? err.message : String(err)}`));
232
+ settleTurn(sid, bracket.turn, messages);
225
233
  runner.enqueue(sid, messages, mode);
226
234
  }
227
235
  if (recovered > 0 || skipped > 0) {
228
- logger.info(`[memory] resume 对账:水位线 ${watermark ?? '无'} → 恢复 ${recovered} 轮 ${total} 条,幂等跳过 ${skipped} 轮(session=${sid})`);
236
+ logger.info(`[memory] resume 对账:水位线 ${watermarkTurn ?? '无'} → 恢复 ${recovered} 轮 ${total} 条,幂等跳过 ${skipped} 轮(session=${sid})`);
229
237
  }
230
238
  }
231
239
  catch (err) {
232
240
  logger.warn(`[memory] resume 恢复失败(不阻塞会话启动): ${err instanceof Error ? err.message : String(err)}`);
233
241
  }
234
242
  });
235
- // 冲刷 = 等待串行链排空(链上每环自带 catch,永不 reject)。
236
- // 注:极小概率在 await 期间又入队的新消息会落到链尾——其 JSONL 事实源照写、
237
- // DB 写入由 upsert 内部兜底(关库后 warn),下次重建自愈。
238
- return () => l0Queue;
243
+ // 冲刷:写停后无排队写(水位登记同步完成),恒立即完成。保留异步签名
244
+ // 供 dispose 序 await(index.ts 停机顺序不变)。
245
+ return async () => { };
239
246
  }
240
247
  /**
241
248
  * 缓冲上限裁剪(防御性;RELEVANT_TYPES 过滤后基本不可达)。
@@ -78,6 +78,17 @@ export interface OwnInjectionFacts {
78
78
  * 署名判定走 `isOwnRecallSource`,同时吃 v4 `plugin:memory` 与 v3 旧行形状。
79
79
  */
80
80
  export declare function ownInjectionFromEvent(ev: InjectionEventLike): OwnInjectionFacts | undefined;
81
+ /**
82
+ * 会话事件统一读形状(issue #8):旧宿主 `session.events` 是数组属性;
83
+ * 0.2.x 宿主已删该属性,官方访问器是 `snapshotEvents()`(全量日志,seq 与日志
84
+ * 位置对齐,含 fork 前缀——与旧属性同口径)。两者都拿不到时返回 undefined,
85
+ * 由调用点的既有退路处置(粗标记 / 存储回填);不假设形状,不抛。
86
+ * 导出供双形状单测钉住。
87
+ */
88
+ export declare function readSessionEvents(session: {
89
+ events?: readonly InjectionEventLike[];
90
+ snapshotEvents?: () => readonly InjectionEventLike[];
91
+ }): readonly InjectionEventLike[] | undefined;
81
92
  export declare function buildRecallQuery(messages: Array<{
82
93
  content: unknown;
83
94
  }>, tailMessages?: number, maxChars?: number): string;