dsh-prime-memory 0.26.10 → 0.26.12
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/CHANGELOG.de.md +1638 -0
- package/CHANGELOG.en.md +15 -0
- package/CHANGELOG.es.md +1641 -0
- package/CHANGELOG.fr.md +1643 -0
- package/CHANGELOG.it.md +1643 -0
- package/CHANGELOG.ja.md +15 -0
- package/CHANGELOG.ko.md +15 -0
- package/CHANGELOG.md +38 -0
- package/CHANGELOG.ru.md +1640 -0
- package/INSTALL.de.md +92 -0
- package/INSTALL.es.md +92 -0
- package/INSTALL.fr.md +92 -0
- package/INSTALL.it.md +92 -0
- package/INSTALL.ru.md +92 -0
- package/README.de.md +1 -1
- package/README.en.md +3 -3
- package/README.es.md +1 -1
- package/README.fr.md +1 -1
- package/README.it.md +1 -1
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +10 -3
- package/README.ru.md +1 -1
- package/dist/bench-control.js +3 -1
- package/dist/client.js +121 -12
- package/dist/clients/drsc.d.ts +34 -0
- package/dist/clients/drsc.js +56 -11
- package/dist/config.d.ts +10 -1
- package/dist/config.js +37 -18
- package/dist/contract.d.ts +48 -1
- package/dist/graph/gate.d.ts +17 -0
- package/dist/graph/gate.js +11 -0
- package/dist/graph/preview.d.ts +5 -0
- package/dist/graph/preview.js +2 -0
- package/dist/index.js +1 -1
- package/dist/pipeline/fact-source.d.ts +127 -0
- package/dist/pipeline/fact-source.js +192 -0
- package/dist/pipeline/rebuild-preserve.d.ts +31 -2
- package/dist/pipeline/rebuild-preserve.js +40 -14
- package/dist/pipeline/rebuild.d.ts +47 -13
- package/dist/pipeline/rebuild.js +153 -57
- package/dist/pipeline/runner.d.ts +4 -1
- package/dist/pipeline/runner.js +58 -21
- package/dist/pipeline/workspace-relink.js +7 -2
- package/dist/room-review.d.ts +1 -1
- package/dist/room-review.js +10 -5
- package/dist/settings.js +4 -0
- package/dist/stats.js +10 -3
- package/dist/store/graph-store.d.ts +20 -0
- package/dist/store/graph-store.js +50 -1
- package/dist/store/sqlite.d.ts +42 -0
- package/dist/store/sqlite.js +98 -0
- package/dist/tools/index.js +4 -3
- package/dsh.plugin.json +2 -2
- package/package.json +37 -21
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 重建事实源抽象(issue #14-B)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要这一层
|
|
5
|
+
*
|
|
6
|
+
* 重建原先硬编码 `MemoryDb.listL0All()`:写入侧(capture)自 T8.4 起已把 L0 正文停写、
|
|
7
|
+
* 历史面交给 DRSC 投影,读侧却仍只认本地那张**冻结表**。这层把「事实源」抽成可替换的
|
|
8
|
+
* provider,让本地 L0 与 DRSC 两套实现共用同一条重建管线。
|
|
9
|
+
*
|
|
10
|
+
* ## 两个刻意的设计约束
|
|
11
|
+
*
|
|
12
|
+
* 1. **`estimate()` 必须同步**:`RebuildController.start()` 是同步 RPC,要在返回前就
|
|
13
|
+
* 给出体量与"能不能重建"的判定。DRSC 做不到同步报数,故 `FactEstimate.known=false`
|
|
14
|
+
* 表示"未知",调用方据此**跳过**同步前置门(改在 prepare 阶段用实际结果判定)。
|
|
15
|
+
* 2. **`chunks()` 而不是"先全量再分组"**:`listL0All()` 在 3 万条量级上峰值约 88MB 且
|
|
16
|
+
* 整个重建期常驻。按会话产出块,峰值降到单会话量级。`groupL0Sessions` 仍保留为
|
|
17
|
+
* 纯函数(测试与本地实现的组装点),但生产路径按会话流式拉取。
|
|
18
|
+
*
|
|
19
|
+
* ## 不丢优先
|
|
20
|
+
*
|
|
21
|
+
* `snapshotIds()` 给 `planPreserve` 判 `post-watermark` 用。DRSC 实现**无法**枚举消息 id
|
|
22
|
+
* (id 是采集端合成的 `drsc:<sid>:<seq>`,与事实源里的 `source_message_ids` 不同域),
|
|
23
|
+
* 因此返回空集 —— 效果是**全部判为不可重蒸、全部保留**。这是安全侧失败:多保留只是多
|
|
24
|
+
* 一条待去重,丢掉是不可逆。
|
|
25
|
+
*/
|
|
26
|
+
import type { ConversationMessage, MemoryLogger } from '../types.js';
|
|
27
|
+
import type { DrscClient } from '../clients/drsc.js';
|
|
28
|
+
import type { MemoryDb } from '../store/sqlite.js';
|
|
29
|
+
/** 重建分块:一个会话的全部消息(按时间升序)。 */
|
|
30
|
+
export interface RebuildChunk {
|
|
31
|
+
sessionId: string;
|
|
32
|
+
messages: ConversationMessage[];
|
|
33
|
+
}
|
|
34
|
+
/** 事实源侧的一条消息(与 `L0MessageRecord` 结构兼容,但不带 recordedAt/turn/step)。 */
|
|
35
|
+
export interface FactMessage {
|
|
36
|
+
id: string;
|
|
37
|
+
sessionId: string;
|
|
38
|
+
role: 'user' | 'assistant';
|
|
39
|
+
content: string;
|
|
40
|
+
timestamp: number;
|
|
41
|
+
}
|
|
42
|
+
/** 体量预估。`known=false` = 事实源无法同步报数(DRSC),字段值不可作为判定依据。 */
|
|
43
|
+
export interface FactEstimate {
|
|
44
|
+
sessions: number;
|
|
45
|
+
messages: number;
|
|
46
|
+
chars: number;
|
|
47
|
+
known: boolean;
|
|
48
|
+
}
|
|
49
|
+
/** 事实源覆盖范围(面板"重建只覆盖到 X"的依据)。 */
|
|
50
|
+
export interface FactCoverage {
|
|
51
|
+
/** 覆盖到的时刻(ISO);null = 未知/无正文。 */
|
|
52
|
+
untilIso: string | null;
|
|
53
|
+
sessions: number;
|
|
54
|
+
}
|
|
55
|
+
export interface FactSourceProvider {
|
|
56
|
+
readonly kind: 'l0-local' | 'drsc';
|
|
57
|
+
/** 同步体量预估;`known=false` 时调用方不得据此做拒绝判定。 */
|
|
58
|
+
estimate(): FactEstimate;
|
|
59
|
+
/**
|
|
60
|
+
* 异步精确预估 + 内部预取(DRSC 在这里一次性枚举会话,`chunks()` 复用缓存)。
|
|
61
|
+
*
|
|
62
|
+
* 与 `estimate()` 的分工:后者是同步门(`start()` 返回前就要有结论),
|
|
63
|
+
* 前者是 prepare 阶段的真实体量(能发请求、能报准确会话数)。
|
|
64
|
+
*/
|
|
65
|
+
prepare(): Promise<FactEstimate>;
|
|
66
|
+
/** 消息级流(诊断/测试用;生产走 chunks)。 */
|
|
67
|
+
snapshot(): AsyncIterable<FactMessage>;
|
|
68
|
+
/** 会话级流 —— 生产路径:单会话内存,不驻留全量正文。 */
|
|
69
|
+
chunks(): AsyncIterable<RebuildChunk>;
|
|
70
|
+
/**
|
|
71
|
+
* 本次事实源的**消息 id 集**,供 `post-watermark` 判据。
|
|
72
|
+
* 空集 = "判不出来" ⇒ 所有有来源的记录都会被保守判为不可重蒸并保留。
|
|
73
|
+
*/
|
|
74
|
+
snapshotIds(): Promise<ReadonlySet<string>>;
|
|
75
|
+
coverage(): FactCoverage;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* 把一批事实消息按会话分组(纯函数)。
|
|
79
|
+
*
|
|
80
|
+
* 保留这个签名是因为它有两个真实用途:① 本地实现按"一次全量"路径时可复用;
|
|
81
|
+
* ② 测试要能直接断言分组与排序语义而不必起 provider。生产路径用 `chunks()` 流式,
|
|
82
|
+
* 不经过这里 —— 否则又把全量正文攒回内存了。
|
|
83
|
+
*
|
|
84
|
+
* **仍然做归一 + 过滤**(与迁移前同口径):入参允许是原始 L0 行,非法行(id 空、
|
|
85
|
+
* role 非 user/assistant、正文空)一律丢弃。过滤不能只留在 provider 里,
|
|
86
|
+
* 否则任何直接调本函数的调用方(含测试夹具)都得自己重复一遍。
|
|
87
|
+
*/
|
|
88
|
+
export declare function groupL0Sessions(records: Iterable<FactMessage | Readonly<Record<string, unknown>>>): RebuildChunk[];
|
|
89
|
+
/** 本地 L0 事实源(默认):已停写的冻结存量,读面走会话点查避免全量驻留。 */
|
|
90
|
+
export declare class LocalL0FactSource implements FactSourceProvider {
|
|
91
|
+
private readonly db;
|
|
92
|
+
private readonly logger;
|
|
93
|
+
readonly kind: "l0-local";
|
|
94
|
+
constructor(db: MemoryDb, logger: MemoryLogger);
|
|
95
|
+
estimate(): FactEstimate;
|
|
96
|
+
prepare(): Promise<FactEstimate>;
|
|
97
|
+
coverage(): FactCoverage;
|
|
98
|
+
snapshotIds(): Promise<ReadonlySet<string>>;
|
|
99
|
+
snapshot(): AsyncIterable<FactMessage>;
|
|
100
|
+
chunks(): AsyncIterable<RebuildChunk>;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* DRSC 事实源(实验性,默认不用):枚举会话 → 逐会话拉全量。
|
|
104
|
+
*
|
|
105
|
+
* 顺序说明:collector 的 `op=sessions` 按 `last_time_ms` **降序**返回,这里显式反转成
|
|
106
|
+
* 升序,与本地实现的"会话按首条时间升序"对齐(上下文链的衔接方向一致)。
|
|
107
|
+
*/
|
|
108
|
+
export declare class DrscFactSource implements FactSourceProvider {
|
|
109
|
+
private readonly client;
|
|
110
|
+
private readonly logger;
|
|
111
|
+
/** 会话分页大小(collector 侧钳 1..500)。 */
|
|
112
|
+
private readonly pageSize;
|
|
113
|
+
readonly kind: "drsc";
|
|
114
|
+
constructor(client: DrscClient, logger: MemoryLogger,
|
|
115
|
+
/** 会话分页大小(collector 侧钳 1..500)。 */
|
|
116
|
+
pageSize?: number);
|
|
117
|
+
estimate(): FactEstimate;
|
|
118
|
+
/** 会话枚举缓存(prepare 拉一次,chunks/snapshot 复用,不重复请求)。 */
|
|
119
|
+
private sessionsCache;
|
|
120
|
+
prepare(): Promise<FactEstimate>;
|
|
121
|
+
coverage(): FactCoverage;
|
|
122
|
+
snapshotIds(): Promise<ReadonlySet<string>>;
|
|
123
|
+
private listAllSessions;
|
|
124
|
+
private sessions;
|
|
125
|
+
snapshot(): AsyncIterable<FactMessage>;
|
|
126
|
+
chunks(): AsyncIterable<RebuildChunk>;
|
|
127
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { drscRoleOf } from '../clients/drsc.js';
|
|
2
|
+
/** L0 记录 → 事实消息(角色/内容过滤与 `groupL0Sessions` 同口径)。 */
|
|
3
|
+
function factMessageOf(rec) {
|
|
4
|
+
const id = typeof rec.id === 'string' ? rec.id : '';
|
|
5
|
+
if (id === '')
|
|
6
|
+
return undefined;
|
|
7
|
+
const role = rec.role === 'user' || rec.role === 'assistant' ? rec.role : undefined;
|
|
8
|
+
if (role === undefined)
|
|
9
|
+
return undefined;
|
|
10
|
+
const content = typeof rec.content === 'string' ? rec.content : '';
|
|
11
|
+
if (content.trim() === '')
|
|
12
|
+
return undefined;
|
|
13
|
+
return {
|
|
14
|
+
id,
|
|
15
|
+
sessionId: typeof rec.sessionId === 'string' && rec.sessionId !== '' ? rec.sessionId : 'default',
|
|
16
|
+
role,
|
|
17
|
+
content,
|
|
18
|
+
timestamp: typeof rec.timestamp === 'number' && Number.isFinite(rec.timestamp) ? rec.timestamp : 0,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 把一批事实消息按会话分组(纯函数)。
|
|
23
|
+
*
|
|
24
|
+
* 保留这个签名是因为它有两个真实用途:① 本地实现按"一次全量"路径时可复用;
|
|
25
|
+
* ② 测试要能直接断言分组与排序语义而不必起 provider。生产路径用 `chunks()` 流式,
|
|
26
|
+
* 不经过这里 —— 否则又把全量正文攒回内存了。
|
|
27
|
+
*
|
|
28
|
+
* **仍然做归一 + 过滤**(与迁移前同口径):入参允许是原始 L0 行,非法行(id 空、
|
|
29
|
+
* role 非 user/assistant、正文空)一律丢弃。过滤不能只留在 provider 里,
|
|
30
|
+
* 否则任何直接调本函数的调用方(含测试夹具)都得自己重复一遍。
|
|
31
|
+
*/
|
|
32
|
+
export function groupL0Sessions(
|
|
33
|
+
// 入参保持宽松:原始 L0 行(多带 recordedAt/turn/step)与 FactMessage 都能喂。
|
|
34
|
+
records) {
|
|
35
|
+
const bySession = new Map();
|
|
36
|
+
for (const raw of records) {
|
|
37
|
+
const msg = factMessageOf(raw);
|
|
38
|
+
if (msg === undefined)
|
|
39
|
+
continue;
|
|
40
|
+
const arr = bySession.get(msg.sessionId) ?? [];
|
|
41
|
+
arr.push({ id: msg.id, role: msg.role, content: msg.content, timestamp: msg.timestamp });
|
|
42
|
+
bySession.set(msg.sessionId, arr);
|
|
43
|
+
}
|
|
44
|
+
const chunks = [];
|
|
45
|
+
for (const [sessionId, messages] of bySession) {
|
|
46
|
+
messages.sort((a, b) => a.timestamp - b.timestamp);
|
|
47
|
+
chunks.push({ sessionId, messages });
|
|
48
|
+
}
|
|
49
|
+
// 会话按首条消息时间升序:情境链按时间顺序衔接(与原始发生顺序一致)
|
|
50
|
+
chunks.sort((a, b) => a.messages[0].timestamp - b.messages[0].timestamp);
|
|
51
|
+
return chunks;
|
|
52
|
+
}
|
|
53
|
+
/** 本地 L0 事实源(默认):已停写的冻结存量,读面走会话点查避免全量驻留。 */
|
|
54
|
+
export class LocalL0FactSource {
|
|
55
|
+
db;
|
|
56
|
+
logger;
|
|
57
|
+
kind = 'l0-local';
|
|
58
|
+
constructor(db, logger) {
|
|
59
|
+
this.db = db;
|
|
60
|
+
this.logger = logger;
|
|
61
|
+
}
|
|
62
|
+
estimate() {
|
|
63
|
+
const est = this.db.l0RebuildEstimate();
|
|
64
|
+
return { ...est, known: true };
|
|
65
|
+
}
|
|
66
|
+
async prepare() {
|
|
67
|
+
return this.estimate();
|
|
68
|
+
}
|
|
69
|
+
coverage() {
|
|
70
|
+
const wm = this.db.rebuildWatermark();
|
|
71
|
+
return { untilIso: wm.l0Iso, sessions: wm.l0SessionCount };
|
|
72
|
+
}
|
|
73
|
+
async snapshotIds() {
|
|
74
|
+
// 只读 record_id 列,不碰正文 —— 这是"不发 88MB 也要能判 post-watermark"的关键。
|
|
75
|
+
return this.db.l0MessageIds();
|
|
76
|
+
}
|
|
77
|
+
async *snapshot() {
|
|
78
|
+
for (const sessionId of this.db.l0SessionIds()) {
|
|
79
|
+
for (const rec of this.db.l0MessagesBySession(sessionId)) {
|
|
80
|
+
const msg = factMessageOf({ ...rec, id: rec.id });
|
|
81
|
+
if (msg !== undefined)
|
|
82
|
+
yield msg;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async *chunks() {
|
|
87
|
+
const sessions = this.db.l0SessionIds();
|
|
88
|
+
this.logger.debug?.(`[memory] 重建事实源:本地 L0,${sessions.length} 个会话(按会话流式读)`);
|
|
89
|
+
for (const sessionId of sessions) {
|
|
90
|
+
const messages = [];
|
|
91
|
+
for (const rec of this.db.l0MessagesBySession(sessionId)) {
|
|
92
|
+
const msg = factMessageOf({ ...rec, id: rec.id });
|
|
93
|
+
if (msg !== undefined)
|
|
94
|
+
messages.push({ id: msg.id, role: msg.role, content: msg.content, timestamp: msg.timestamp });
|
|
95
|
+
}
|
|
96
|
+
if (messages.length > 0)
|
|
97
|
+
yield { sessionId, messages };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* DRSC 事实源(实验性,默认不用):枚举会话 → 逐会话拉全量。
|
|
103
|
+
*
|
|
104
|
+
* 顺序说明:collector 的 `op=sessions` 按 `last_time_ms` **降序**返回,这里显式反转成
|
|
105
|
+
* 升序,与本地实现的"会话按首条时间升序"对齐(上下文链的衔接方向一致)。
|
|
106
|
+
*/
|
|
107
|
+
export class DrscFactSource {
|
|
108
|
+
client;
|
|
109
|
+
logger;
|
|
110
|
+
pageSize;
|
|
111
|
+
kind = 'drsc';
|
|
112
|
+
constructor(client, logger,
|
|
113
|
+
/** 会话分页大小(collector 侧钳 1..500)。 */
|
|
114
|
+
pageSize = 200) {
|
|
115
|
+
this.client = client;
|
|
116
|
+
this.logger = logger;
|
|
117
|
+
this.pageSize = pageSize;
|
|
118
|
+
}
|
|
119
|
+
estimate() {
|
|
120
|
+
// 同步拿不到(要发请求)。known=false 让调用方跳过同步前置门,
|
|
121
|
+
// 真正的“空事实源”判定落在 prepare 的会话数上。
|
|
122
|
+
return { sessions: 0, messages: 0, chars: 0, known: false };
|
|
123
|
+
}
|
|
124
|
+
/** 会话枚举缓存(prepare 拉一次,chunks/snapshot 复用,不重复请求)。 */
|
|
125
|
+
sessionsCache = null;
|
|
126
|
+
async prepare() {
|
|
127
|
+
this.sessionsCache = await this.listAllSessions();
|
|
128
|
+
return { sessions: this.sessionsCache.length, messages: 0, chars: 0, known: true };
|
|
129
|
+
}
|
|
130
|
+
coverage() {
|
|
131
|
+
return { untilIso: null, sessions: 0 };
|
|
132
|
+
}
|
|
133
|
+
async snapshotIds() {
|
|
134
|
+
this.logger.warn('[memory] DRSC 事实源无法枚举消息 id(drsc:<sid>:<seq> 与 source_message_ids 不同域)——'
|
|
135
|
+
+ '本轮重建会把所有有来源的记忆判为不可重蒸并原样保留(保守侧失败,不丢数据)');
|
|
136
|
+
return new Set();
|
|
137
|
+
}
|
|
138
|
+
async listAllSessions() {
|
|
139
|
+
const ids = [];
|
|
140
|
+
let offset = 0;
|
|
141
|
+
for (;;) {
|
|
142
|
+
const page = await this.client.listSessions(offset, this.pageSize);
|
|
143
|
+
for (const row of page.rows) {
|
|
144
|
+
if (typeof row.session_id === 'string' && row.session_id !== '') {
|
|
145
|
+
ids.push({ id: row.session_id, last: row.last_time_ms });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
offset += page.rows.length;
|
|
149
|
+
if (page.rows.length === 0 || offset >= page.total)
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
// 反转 collector 的 last_time_ms 降序 → 升序(见类注释)
|
|
153
|
+
return ids.sort((a, b) => a.last - b.last).map((r) => r.id);
|
|
154
|
+
}
|
|
155
|
+
async sessions() {
|
|
156
|
+
if (this.sessionsCache !== null)
|
|
157
|
+
return this.sessionsCache;
|
|
158
|
+
this.sessionsCache = await this.listAllSessions();
|
|
159
|
+
return this.sessionsCache;
|
|
160
|
+
}
|
|
161
|
+
async *snapshot() {
|
|
162
|
+
for (const sessionId of await this.sessions()) {
|
|
163
|
+
for (const row of await this.client.sessionAll(sessionId)) {
|
|
164
|
+
const role = drscRoleOf(row.kind);
|
|
165
|
+
if (role === undefined)
|
|
166
|
+
continue;
|
|
167
|
+
if (row.text.trim() === '')
|
|
168
|
+
continue;
|
|
169
|
+
yield { id: `drsc:${sessionId}:${row.seq}`, sessionId, role, content: row.text, timestamp: row.time };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async *chunks() {
|
|
174
|
+
const sessions = await this.sessions();
|
|
175
|
+
this.logger.info(`[memory] 重建事实源:DRSC,${sessions.length} 个会话(逐会话拉全量,redact=1)`);
|
|
176
|
+
for (const sessionId of sessions) {
|
|
177
|
+
const rows = await this.client.sessionAll(sessionId);
|
|
178
|
+
const messages = [];
|
|
179
|
+
for (const row of rows) {
|
|
180
|
+
const role = drscRoleOf(row.kind);
|
|
181
|
+
if (role === undefined)
|
|
182
|
+
continue;
|
|
183
|
+
if (row.text.trim() === '')
|
|
184
|
+
continue;
|
|
185
|
+
messages.push({ id: `drsc:${sessionId}:${row.seq}`, role, content: row.text, timestamp: row.time });
|
|
186
|
+
}
|
|
187
|
+
messages.sort((a, b) => a.timestamp - b.timestamp);
|
|
188
|
+
if (messages.length > 0)
|
|
189
|
+
yield { sessionId, messages };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -54,8 +54,19 @@ export interface FactSourceRead {
|
|
|
54
54
|
export type PreserveReason =
|
|
55
55
|
/** 事实源里没有 `source_message_ids` —— 不是从 L0 蒸馏来的。 */
|
|
56
56
|
'no-source'
|
|
57
|
-
/** 内容为空白 ——
|
|
57
|
+
/** 内容为空白 —— 重蒸馏不会产出空记忆,保留它也无从重造。 */
|
|
58
58
|
| 'empty-content'
|
|
59
|
+
/**
|
|
60
|
+
* 来源消息**不在本次 L0 快照内**。
|
|
61
|
+
*
|
|
62
|
+
* 与 `no-source` 的区别:这条记录**有**来源 id,但那些 id 指向的消息不在本次
|
|
63
|
+
* 重建的事实源里——结构上重蒸不出来,故必须保留。
|
|
64
|
+
*
|
|
65
|
+
* 典型成因是 L0 写停(T8.4 后 l0 正文三表退役):停写之后新增的 L1 记录
|
|
66
|
+
* 全部落这一类。判据用**集合包含**而不是时间差——L1 的 `created_time` 会随
|
|
67
|
+
* 每次蒸馏前移,任何时间阈值终将被跳过,用它做门槛会把重建**永久锁死**。
|
|
68
|
+
*/
|
|
69
|
+
| 'post-watermark'
|
|
59
70
|
/**
|
|
60
71
|
* 检索库里有、事实源里**完全没有**这一行 —— 来源无从证明。
|
|
61
72
|
*
|
|
@@ -86,6 +97,14 @@ export interface PreservePlan {
|
|
|
86
97
|
}
|
|
87
98
|
/** 判定单条记录是否可由 L0 重蒸馏出来(事实源侧判据)。 */
|
|
88
99
|
export declare function isRederivable(record: MemoryRecord): boolean;
|
|
100
|
+
/**
|
|
101
|
+
* 来源是否**全部**落在本次快照内。
|
|
102
|
+
*
|
|
103
|
+
* `every` 而不是 `some`:只要有一张原料缺失,重蒸馏就只会产出一个缺角的新记录,
|
|
104
|
+
* 不如原样保留。无来源时返回 `false`——但那一类由 `no-source` 先行接住,
|
|
105
|
+
* 不应依赖本函数的返回值。
|
|
106
|
+
*/
|
|
107
|
+
export declare function sourcesCoveredBySnapshot(record: MemoryRecord, snapshotIds: ReadonlySet<string>): boolean;
|
|
89
108
|
/**
|
|
90
109
|
* 读取事实源目录(`<dataDir>/records/*.jsonl`)。
|
|
91
110
|
*
|
|
@@ -103,7 +122,17 @@ export declare function readFactSource(recordsDir: string): Promise<FactSourceRe
|
|
|
103
122
|
* `liveDbRecords` 由调用方从**检索库**取(它没有 `source_message_ids` 列,
|
|
104
123
|
* 只用来回答"这条还在不在"),恢复时以检索库副本为准 —— 那是当前检索态。
|
|
105
124
|
*/
|
|
106
|
-
export
|
|
125
|
+
export interface PreserveOptions {
|
|
126
|
+
/**
|
|
127
|
+
* 本次重建事实源(L0 快照)的**消息 id 集**。
|
|
128
|
+
*
|
|
129
|
+
* 不给 = 保持旧口径(只看"有无来源")。给了才能判 `post-watermark`,
|
|
130
|
+
* 而**给了才是安全的**:L0 已写停的库只看"有无来源"会把停写后的记录
|
|
131
|
+
* 当成可重蒸,清库后真的丢。
|
|
132
|
+
*/
|
|
133
|
+
readonly snapshotIds?: ReadonlySet<string>;
|
|
134
|
+
}
|
|
135
|
+
export declare function planPreserve(factRecords: readonly MemoryRecord[], liveDbRecords: readonly MemoryRecord[], opts?: PreserveOptions): PreservePlan;
|
|
107
136
|
/** 清空闸门的判定码。 */
|
|
108
137
|
export type ClearGateCode =
|
|
109
138
|
/** 放行。 */
|
|
@@ -44,6 +44,19 @@ export function isRederivable(record) {
|
|
|
44
44
|
const src = record.source_message_ids;
|
|
45
45
|
return Array.isArray(src) && src.length > 0;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* 来源是否**全部**落在本次快照内。
|
|
49
|
+
*
|
|
50
|
+
* `every` 而不是 `some`:只要有一张原料缺失,重蒸馏就只会产出一个缺角的新记录,
|
|
51
|
+
* 不如原样保留。无来源时返回 `false`——但那一类由 `no-source` 先行接住,
|
|
52
|
+
* 不应依赖本函数的返回值。
|
|
53
|
+
*/
|
|
54
|
+
export function sourcesCoveredBySnapshot(record, snapshotIds) {
|
|
55
|
+
const src = record.source_message_ids;
|
|
56
|
+
if (!Array.isArray(src) || src.length === 0)
|
|
57
|
+
return false;
|
|
58
|
+
return src.every((id) => snapshotIds.has(id));
|
|
59
|
+
}
|
|
47
60
|
/**
|
|
48
61
|
* 读取事实源目录(`<dataDir>/records/*.jsonl`)。
|
|
49
62
|
*
|
|
@@ -89,16 +102,7 @@ export async function readFactSource(recordsDir) {
|
|
|
89
102
|
}
|
|
90
103
|
return { ok: true, records: [...byId.values()], files: names.length };
|
|
91
104
|
}
|
|
92
|
-
|
|
93
|
-
* 出保留计划。纯函数:输入事实源记录 + 检索库现存记录,输出要保留什么。
|
|
94
|
-
*
|
|
95
|
-
* 两个方向都保守:事实源说"这条没来源"要留,**检索库里查不到出处**也要留
|
|
96
|
-
* (`unprovenanced`)—— 判据缺失时留,而不是丢。
|
|
97
|
-
*
|
|
98
|
-
* `liveDbRecords` 由调用方从**检索库**取(它没有 `source_message_ids` 列,
|
|
99
|
-
* 只用来回答"这条还在不在"),恢复时以检索库副本为准 —— 那是当前检索态。
|
|
100
|
-
*/
|
|
101
|
-
export function planPreserve(factRecords, liveDbRecords) {
|
|
105
|
+
export function planPreserve(factRecords, liveDbRecords, opts = {}) {
|
|
102
106
|
const live = new Map();
|
|
103
107
|
for (const r of liveDbRecords)
|
|
104
108
|
if (r && typeof r.id === 'string')
|
|
@@ -112,6 +116,21 @@ export function planPreserve(factRecords, liveDbRecords) {
|
|
|
112
116
|
continue;
|
|
113
117
|
seen.add(f.id);
|
|
114
118
|
if (isRederivable(f)) {
|
|
119
|
+
// 顺序纪律:「外部可重蒸」必须在「post-watermark 保留」**之后**判。
|
|
120
|
+
// 停写后新增的记录 `source_message_ids` 非空,但指向的消息在本地
|
|
121
|
+
// `l0_conversations` 已查不到 —— 清库后蒸不出来,等于丢失;
|
|
122
|
+
// 而按旧口径(只看非空)它们恰好会被归进 `rederivable` 然后被清掉。
|
|
123
|
+
if (opts.snapshotIds && !sourcesCoveredBySnapshot(f, opts.snapshotIds)) {
|
|
124
|
+
const current = live.get(f.id);
|
|
125
|
+
// 已退场的不复活(与 no-source 分支同口径)
|
|
126
|
+
if (current) {
|
|
127
|
+
preserved.push({
|
|
128
|
+
record: current,
|
|
129
|
+
reason: String(current.content ?? '').trim() ? 'post-watermark' : 'empty-content',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
115
134
|
// 有来源但已退场(合并掉了)的不必计数——重建会按 L0 再造。
|
|
116
135
|
if (live.has(f.id))
|
|
117
136
|
rederivable++;
|
|
@@ -175,8 +194,8 @@ export function gateClear(read, plan) {
|
|
|
175
194
|
allowed: true,
|
|
176
195
|
code: 'ok',
|
|
177
196
|
note: keep > 0
|
|
178
|
-
? `保留 ${keep}
|
|
179
|
-
:
|
|
197
|
+
? `保留 ${keep} 条不可重蒸的记忆(导入/手工写入/来源不在本次快照),重建只重造 ${plan.rederivable} 条有来源的记忆`
|
|
198
|
+
: `无不可重蒸记忆需要保留,重建重造 ${plan.rederivable} 条有来源的记忆`,
|
|
180
199
|
};
|
|
181
200
|
}
|
|
182
201
|
/**
|
|
@@ -195,12 +214,19 @@ export async function restorePreserved(l1, plan) {
|
|
|
195
214
|
const missing = ids.filter((id) => !back.has(id));
|
|
196
215
|
return { attempted: ids.length, restored: ids.length - missing.length, missing };
|
|
197
216
|
}
|
|
217
|
+
/** reason → 中文标签(日志/状态栏直接给人看,不再打印英文枚举)。 */
|
|
218
|
+
const REASON_LABEL = {
|
|
219
|
+
'no-source': '无来源',
|
|
220
|
+
'empty-content': '内容为空',
|
|
221
|
+
unprovenanced: '来源无从证明',
|
|
222
|
+
'post-watermark': '来源不在本次快照',
|
|
223
|
+
};
|
|
198
224
|
/** 面向日志/状态栏的一行摘要。 */
|
|
199
225
|
export function describePlan(plan) {
|
|
200
226
|
const byReason = new Map();
|
|
201
227
|
for (const p of plan.preserved)
|
|
202
228
|
byReason.set(p.reason, (byReason.get(p.reason) ?? 0) + 1);
|
|
203
|
-
const parts = [...byReason].map(([r, n]) => `${r} ${n}`);
|
|
204
|
-
return (`保留 ${plan.preserved.length}
|
|
229
|
+
const parts = [...byReason].map(([r, n]) => `${REASON_LABEL[r] ?? r} ${n}`);
|
|
230
|
+
return (`保留 ${plan.preserved.length} 条不可重蒸记忆${parts.length ? `(${parts.join(' / ')})` : ''};` +
|
|
205
231
|
`可重蒸馏 ${plan.rederivable} 条;事实源 ${plan.factCount} 条(其中无来源但已退场 ${plan.retiredNoSource} 条,不恢复)`);
|
|
206
232
|
}
|
|
@@ -6,24 +6,36 @@ import type { SceneStore } from '../store/scenes.js';
|
|
|
6
6
|
import type { StateStore } from '../store/state.js';
|
|
7
7
|
import type { MemoryDb } from '../store/sqlite.js';
|
|
8
8
|
import type { LiveSettingsHandle } from '../settings.js';
|
|
9
|
-
import type {
|
|
9
|
+
import type { MemoryFamily, MemoryLogger } from '../types.js';
|
|
10
10
|
import { type MemoryRunner } from './runner.js';
|
|
11
|
+
import type { DrscClient } from '../clients/drsc.js';
|
|
12
|
+
export type { FactCoverage, FactEstimate, FactMessage, FactSourceProvider, RebuildChunk } from './fact-source.js';
|
|
13
|
+
export { DrscFactSource, groupL0Sessions, LocalL0FactSource } from './fact-source.js';
|
|
11
14
|
/** 重建所需的存储子集(l0 不需要——快照直接走 db)。 */
|
|
12
15
|
export interface RebuildStores {
|
|
13
16
|
l1: L1Store;
|
|
14
17
|
scenes: Record<MemoryFamily, SceneStore>;
|
|
15
18
|
persona: Record<MemoryFamily, PersonaStore>;
|
|
16
19
|
state: StateStore;
|
|
20
|
+
/**
|
|
21
|
+
* DRSC 客户端(issue #14-B):仅当 `rebuild.factSource === 'drsc'` 时用到。
|
|
22
|
+
* 缺席时选 drsc 会**显式中止**,不静默回退本地表。
|
|
23
|
+
*/
|
|
24
|
+
drsc?: DrscClient;
|
|
17
25
|
}
|
|
18
26
|
import type { RebuildStatus } from '../contract.js';
|
|
19
27
|
export type { RebuildPhase, RebuildStatus } from '../contract.js';
|
|
20
|
-
export interface RebuildChunk {
|
|
21
|
-
sessionId: string;
|
|
22
|
-
messages: ConversationMessage[];
|
|
23
|
-
}
|
|
24
|
-
export declare function groupL0Sessions(records: L0MessageRecord[]): RebuildChunk[];
|
|
25
28
|
/** 抽取调用数下界估算(与 l1.ts 的 perChunk 同式;会话数与字符预算取大)。 */
|
|
26
29
|
export declare function estimateCalls(sessions: number, messages: number, chars: number, maxInputChars: number): number;
|
|
30
|
+
/**
|
|
31
|
+
* 水位落差**提示**阀值:检索库里晚于 L0 水位的占比超过此值,就要求二次确认。
|
|
32
|
+
*
|
|
33
|
+
* 为什么叫“提示阀值”而不是“拒绝阀值”:L0 写停后这个比例只会单调上升,
|
|
34
|
+
* 拿它做硬门槛等于把重建**永久锁死**(issue #14 原提案的致命缺陷)。
|
|
35
|
+
* 所以它只负责“提醒你正在做什么”,放行靠 `force`;真正的**不丢**由
|
|
36
|
+
* `planPreserve` 的集合判据(`post-watermark`)结构性保证。
|
|
37
|
+
*/
|
|
38
|
+
export declare const STALE_RATIO_THRESHOLD = 0.5;
|
|
27
39
|
export declare class RebuildController {
|
|
28
40
|
private readonly ctx;
|
|
29
41
|
private readonly cfg;
|
|
@@ -33,22 +45,44 @@ export declare class RebuildController {
|
|
|
33
45
|
private readonly logger;
|
|
34
46
|
private readonly live;
|
|
35
47
|
private status;
|
|
36
|
-
|
|
48
|
+
/**
|
|
49
|
+
* 会话级事实源迭代器(issue #14-B):不再把全量 L0 正文驻留内存。
|
|
50
|
+
* null = 尚未进入蒸馏 / 已收尾。
|
|
51
|
+
*/
|
|
52
|
+
private chunkIter;
|
|
37
53
|
private cancelRequested;
|
|
38
54
|
/** 快照时刻(收尾时按它区分重建产物与重建后新对话的记录)。 */
|
|
39
55
|
private rebuildStartMs;
|
|
40
56
|
constructor(ctx: Context, cfg: MemoryConfig, stores: RebuildStores, db: MemoryDb, runner: Pick<MemoryRunner, 'enqueueRebuildTask' | 'runRebuildTurn' | 'states'>, logger: MemoryLogger, live: LiveSettingsHandle);
|
|
41
|
-
/** 状态快照(idle
|
|
57
|
+
/** 状态快照(idle 时附带实时预估与事实源水位,供确认弹窗讲清"会发生什么")。 */
|
|
42
58
|
getStatus(): RebuildStatus;
|
|
43
|
-
/**
|
|
59
|
+
/** 尚未处理的会话块数(流式下为估算 `total - done`;未运行时为 0,诊断/冒烟用)。 */
|
|
44
60
|
get chunkCount(): number;
|
|
45
|
-
/**
|
|
46
|
-
|
|
61
|
+
/**
|
|
62
|
+
* 按 `cfg.rebuild.factSource` 选事实源(issue #14-B)。
|
|
63
|
+
*
|
|
64
|
+
* 选 drsc 但客户端未装配时**显式抛错**,不静默回退本地表 —— “以为用了新源其实还是旧表”
|
|
65
|
+
* 会让用户拿重建结果去判断 DRSC 数据是否完整,那是最坏的误判。
|
|
66
|
+
*/
|
|
67
|
+
private provider;
|
|
68
|
+
/**
|
|
69
|
+
* 启动重建(校验后入队准备任务;真正的清库/归档在管线队列里串行执行,避开并发竞态)。
|
|
70
|
+
*
|
|
71
|
+
* `force` 只用于跨过**提示门槛**(水位落差超过 `STALE_RATIO_THRESHOLD` 时的二次确认),
|
|
72
|
+
* 不能跨过唯一那条硬拒:「L0 一条消息都没有、检索库却有记录」—— 那种形状下重建
|
|
73
|
+
* 只会清空、一条也重造不出来,属于纯亏。
|
|
74
|
+
*/
|
|
75
|
+
start(force?: boolean): RebuildStatus;
|
|
47
76
|
/** 请求取消:当前块完成后停止,已重建部分保留并照常收尾 L2/L3。 */
|
|
48
77
|
requestCancel(): RebuildStatus;
|
|
49
78
|
private prepare;
|
|
50
|
-
/**
|
|
51
|
-
|
|
79
|
+
/**
|
|
80
|
+
* 分块链:一次只挂一个重建块,跑完再挂下一块——正常轮次可随时插队。
|
|
81
|
+
*
|
|
82
|
+
* 流式版(issue #14-B):从事实源迭代器拉下一个会话,不再按下标从内存数组取。
|
|
83
|
+
* 内存峰值从"全量正文"降到"单会话"。
|
|
84
|
+
*/
|
|
85
|
+
private scheduleNext;
|
|
52
86
|
private finalize;
|
|
53
87
|
/**
|
|
54
88
|
* 收集重建窗口内某族的记录:重建产物全部是新插入(updated==created),
|