dsh-prime-memory 0.13.1 → 0.14.0-beta.2

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.
@@ -12,6 +12,7 @@ import { buildReceipts, newRunId, persistReceiptsSafely } from '../store/receipt
12
12
  import { buildConflictPair, validateConflictPair } from '../store/conflicts.js';
13
13
  import { formatExtractionPrompt, getExtractMemoriesSystemPrompt } from '../prompts/l1-extraction.js';
14
14
  import { formatBatchConflictPrompt, getConflictDetectionSystemPrompt } from '../prompts/l1-dedup.js';
15
+ import { resolveSourceAnchors, withSourceAnchors } from './anchors.js';
15
16
  import { familyForType, normPersistence, normScope, resolveRecordFamily, resolveRecordScope } from '../types.js';
16
17
  /** 解析 ISO/epoch 时间证据,非法或非正值一律 undefined——不猜测。 */
17
18
  function parseTimeEvidence(raw) {
@@ -51,8 +52,11 @@ function mergeTemporal(self, targets) {
51
52
  * 构造一条**全新**L1 记录(store 语义)。§C 冻结复用同一构造:
52
53
  * "新记忆照常入 L1"必须与既有 store 路径**逐字段一致**,否则冻结会引入
53
54
  * 一种只在开启开关时才出现的新记录形状。
55
+ *
56
+ * 第 4 个参数是 R7 的锚点映射(`L0 消息 id → 会话坐标`)。**传 undefined 时
57
+ * 行为与改动前逐字一致**——老调用方与没有锚点的会话走这条分支。
54
58
  */
55
- function toStoreRecord(m, now, ts) {
59
+ function toStoreRecord(m, now, ts, anchorMap) {
56
60
  return {
57
61
  id: m.record_id,
58
62
  content: m.content,
@@ -64,7 +68,9 @@ function toStoreRecord(m, now, ts) {
64
68
  updatedAt: now,
65
69
  version: 0,
66
70
  source_message_ids: m.source_message_ids ?? [],
67
- metadata: m.metadata ?? {},
71
+ // R7:锚点与 source_message_ids 同源解析;解析不到就**不写键**(不是空数组),
72
+ // 使无锚点记录与改动前的 metadata 逐字一致。
73
+ metadata: withSourceAnchors(m.metadata, anchorMap === undefined ? undefined : resolveSourceAnchors(m.source_message_ids, anchorMap)),
68
74
  family: m.family,
69
75
  scope: m.scope,
70
76
  workspaceId: m.workspaceId,
@@ -104,7 +110,13 @@ export async function runExtraction(ctx, cfg, store, states, pending, background
104
110
  * 传 undefined 时行为与改动前**逐字一致**——`cfg.scope='global'` 的既有部署
105
111
  * 永远走这条分支,这是零漂移的构造性保证。
106
112
  */
107
- workspaceId) {
113
+ workspaceId,
114
+ /**
115
+ * R7 锚点映射(`L0 消息 id → 会话坐标`),由调用方经 `buildAnchorMap` 构造。
116
+ * **缺省时行为与改动前逐字一致**——传入的 `pending` 消息若不带锚点(老数据、
117
+ * 未启用捕获侧打戳),`resolveSourceAnchors` 一律返回 undefined,不写 metadata 键。
118
+ */
119
+ anchorMap) {
108
120
  if (!cfg.extract.enabled)
109
121
  return { stored: 0, skipped: true, sceneName: chainHead(states, mode), newRecords: [] };
110
122
  // 触发阈值(渐进爬坡 + 按会话切片计数)由 runner 判定(trigger.ts);
@@ -272,7 +284,7 @@ workspaceId) {
272
284
  const action = decision.action;
273
285
  const ts = m.metadata?.activity_start_time ? Date.parse(String(m.metadata.activity_start_time)) : now;
274
286
  if (action === 'store') {
275
- added.push(toStoreRecord(m, now, ts));
287
+ added.push(toStoreRecord(m, now, ts, anchorMap));
276
288
  continue;
277
289
  }
278
290
  // ── §C 矛盾冻结:不自动裁决 ──
@@ -286,7 +298,7 @@ workspaceId) {
286
298
  ? validateConflictPair(m.record_id, decision.winner, decision.loser, new Set(byId.keys()))
287
299
  : null;
288
300
  if (pair) {
289
- added.push(toStoreRecord(m, now, ts));
301
+ added.push(toStoreRecord(m, now, ts, anchorMap));
290
302
  const built = buildConflictPair({
291
303
  runId,
292
304
  winnerId: pair.winnerId,
@@ -307,7 +319,7 @@ workspaceId) {
307
319
  else {
308
320
  logger.warn(`[memory] 矛盾冻结:第 ${m.record_id} 条的 conflict 决策无法构成冻结对` +
309
321
  `(winner=${String(decision.winner)} loser=${String(decision.loser)}),已回落 store`);
310
- added.push(toStoreRecord(m, now, ts));
322
+ added.push(toStoreRecord(m, now, ts, anchorMap));
311
323
  }
312
324
  continue;
313
325
  }
@@ -334,7 +346,10 @@ workspaceId) {
334
346
  updatedAt: now,
335
347
  version: targetVersion + 1,
336
348
  source_message_ids: m.source_message_ids ?? [],
337
- metadata: m.metadata ?? {},
349
+ // R7:合并/更新产出的记录同样带锚点——否则"合并一次就丢坐标",
350
+ // 而合并恰恰是长会话里最常发生的动作。无映射时传空表 →
351
+ // resolveSourceAnchors 返回 undefined → 不写 metadata 键(零漂移)。
352
+ metadata: withSourceAnchors(m.metadata, resolveSourceAnchors(m.source_message_ids, anchorMap ?? new Map())),
338
353
  family: m.family,
339
354
  // 合并的有效期取并集:起 = 两侧最早;止 = 任一侧未闭合则仍未闭合(undefined)。
340
355
  ...mergeTemporal(temporalOf(m.metadata), targets.map((id) => byId.get(id)).filter((r) => r !== undefined)),
@@ -0,0 +1,149 @@
1
+ /**
2
+ * 保留式重建:清空 L1 前判定**哪些记忆不可能被 L0 重蒸馏出来**,并把这些保住。
3
+ *
4
+ * ## 为什么需要这个模块
5
+ *
6
+ * `RebuildController.prepare()` 的语义是"清空 L1 检索库 → 从 L0 全量重蒸馏"。
7
+ * 这个语义对**由 L0 蒸馏而来**的记忆是自洽的(清掉也能再造一遍),但对
8
+ * **不由 L0 派生**的记忆是破坏性的:`memory_add` / `memory_import` 写进来的
9
+ * 外部记忆(其他 AI 工具导出的记忆包)在任何会话日志里都没有对应位置,
10
+ * 重建不会再造出它们。实测(`_analysis_output` 探针,2026-09-17)这类记录
11
+ * 当时有 19 条存活,涉及 `__manual__` 与若干外部导入场景名。
12
+ *
13
+ * ## 判据为什么是 `source_message_ids`,而不是"有没有锚点"
14
+ *
15
+ * 计划书原稿写的是"无来源记忆(**导入类 / 无锚点类**)",但实测推翻了后半句:
16
+ *
17
+ * - 检索库 `l1_records` **没有 `source_message_ids` 列**(见 `types.ts:224` 的
18
+ * 既有注释"JSONL 事实源保留;检索库不存该列"),所以判据只能来自**事实源**;
19
+ * - 检索库里 `dsh_source_anchors` 的命中数是 **0/740** —— task_31 落地的锚点
20
+ * 写入**尚未产出任何一行**(没有新的蒸馏发生过)。把"无锚点"当判据会把
21
+ * **全部 740 条**都判成"要保留",重建直接退化成空操作。
22
+ *
23
+ * 所以判据取事实源里的 `source_message_ids`:非空 ⇒ 该记忆的位置在 L0 日志里
24
+ * 有据可查、重建能再造;空/缺失 ⇒ 造不出来,**必须保留**。
25
+ *
26
+ * ## 为什么必须取"事实源 ∩ 检索库"
27
+ *
28
+ * 事实源是**只增不改**的历史(合并/更新产出新 id 后,旧行仍留在 JSONL)。
29
+ * 实测:事实源 1,694 个唯一 id,检索库 740 个,`事实源 ∩ 检索库 == 检索库`
30
+ * (即检索库是事实源的真子集,反向差集为 0)。其中**已从检索库退场、但没有
31
+ * `source_message_ids` 的有 25 条** —— 按事实源无脑恢复会**复活这 25 条已删记录**。
32
+ * 故保留集 = 事实源无来源 **且** 检索库仍存在,两个条件缺一不可。
33
+ *
34
+ * ## 静默缺席的处理
35
+ *
36
+ * 事实源读不出来时,"没有记录需要保留"与"不知道哪些记录需要保留"长得一模一样。
37
+ * 后者若按前者处理,重建就**静默退化**成原来的破坏性行为。故 `gateClear()` 在
38
+ * 事实源不可读(或可读但零记录)**且检索库非空**时**拒绝放行**,而不是放行。
39
+ */
40
+ import type { MemoryRecord } from '../types.js';
41
+ /** 事实源读取结果的失败原因。成功时为 undefined。 */
42
+ export type FactSourceFailure = 'dir-missing' | 'read-error';
43
+ export interface FactSourceRead {
44
+ /** 事实源是否可读。false 时 `records` 必为空。 */
45
+ readonly ok: boolean;
46
+ readonly records: readonly MemoryRecord[];
47
+ /** 实际读到的文件数(诊断用:0 个文件与目录缺失是两回事)。 */
48
+ readonly files: number;
49
+ readonly reason?: FactSourceFailure;
50
+ /** 读取异常原文(仅 `read-error` 时)。 */
51
+ readonly detail?: string;
52
+ }
53
+ /** 保留原因。 */
54
+ export type PreserveReason =
55
+ /** 事实源里没有 `source_message_ids` —— 不是从 L0 蒸馏来的。 */
56
+ 'no-source'
57
+ /** 内容为空白 —— 重蒸馏不会产出空记忆,保留它也无从重造。 */
58
+ | 'empty-content'
59
+ /**
60
+ * 检索库里有、事实源里**完全没有**这一行 —— 来源无从证明。
61
+ *
62
+ * 实测该集合当前为 **0**(检索库 740 ⊆ 事实源 1,694),所以它不是常规路径,
63
+ * 而是**事实源部分损坏时的兜底**:`readJsonl` 会静默跳过坏行(`util/io.ts:78`),
64
+ * 半张文件坏掉时"事实源里没有它"会与"它本来就没有来源"长得一样。
65
+ * 判不出来源的记忆一律留下 —— 留错是多一条待去重的记录,丢掉是不可逆的。
66
+ */
67
+ | 'unprovenanced';
68
+ export interface PreserveEntry {
69
+ readonly record: MemoryRecord;
70
+ readonly reason: PreserveReason;
71
+ }
72
+ export interface PreservePlan {
73
+ /** 清空后要原样放回的记忆。 */
74
+ readonly preserved: readonly PreserveEntry[];
75
+ /** 可由 L0 重蒸馏的记忆条数(不保留,重建会再造)。 */
76
+ readonly rederivable: number;
77
+ /**
78
+ * 事实源里无来源、但**已从检索库退场**的条数 —— 已被合并/取代,
79
+ * 恢复它们等于复活已删记录。**只计数,不恢复**。
80
+ */
81
+ readonly retiredNoSource: number;
82
+ /** 事实源唯一 id 数(含已退场)。 */
83
+ readonly factCount: number;
84
+ /** 检索库条数。 */
85
+ readonly dbCount: number;
86
+ }
87
+ /** 判定单条记录是否可由 L0 重蒸馏出来(事实源侧判据)。 */
88
+ export declare function isRederivable(record: MemoryRecord): boolean;
89
+ /**
90
+ * 读取事实源目录(`<dataDir>/records/*.jsonl`)。
91
+ *
92
+ * 只读 `*.jsonl` 顶层文件:`archiveDerived()` 把旧目录整体改名为
93
+ * `records.bak.<ts>`,它是**兄弟目录**不是文件,不会被 glob 命中。
94
+ * **必须在 `archiveDerived()` 之前调用** —— 归档之后事实源就搬走了。
95
+ */
96
+ export declare function readFactSource(recordsDir: string): Promise<FactSourceRead>;
97
+ /**
98
+ * 出保留计划。纯函数:输入事实源记录 + 检索库现存记录,输出要保留什么。
99
+ *
100
+ * 两个方向都保守:事实源说"这条没来源"要留,**检索库里查不到出处**也要留
101
+ * (`unprovenanced`)—— 判据缺失时留,而不是丢。
102
+ *
103
+ * `liveDbRecords` 由调用方从**检索库**取(它没有 `source_message_ids` 列,
104
+ * 只用来回答"这条还在不在"),恢复时以检索库副本为准 —— 那是当前检索态。
105
+ */
106
+ export declare function planPreserve(factRecords: readonly MemoryRecord[], liveDbRecords: readonly MemoryRecord[]): PreservePlan;
107
+ /** 清空闸门的判定码。 */
108
+ export type ClearGateCode =
109
+ /** 放行。 */
110
+ 'ok'
111
+ /** 放行:检索库本来就是空的,没有可失去的东西。 */
112
+ | 'empty-db'
113
+ /** **拒绝**:事实源目录读不到,而检索库非空 —— 判不出该保留什么。 */
114
+ | 'source-missing'
115
+ /** **拒绝**:事实源可读但零记录,而检索库非空 —— 事实源异常,同上。 */
116
+ | 'source-empty';
117
+ export interface ClearGate {
118
+ readonly allowed: boolean;
119
+ readonly code: ClearGateCode;
120
+ /** 面向用户的中文说明(会进 `RebuildStatus.error`,故必须讲清怎么补救)。 */
121
+ readonly note: string;
122
+ }
123
+ /**
124
+ * 清空前的守门判定。
125
+ *
126
+ * 拒绝而不是放行,是因为"没有需要保留的"与"不知道有没有需要保留的"在数据上
127
+ * 无法区分(见文件头「静默缺席的处理」)。检索库为空时不存在可失去的记忆,
128
+ * 故那一路照常放行 —— 不把功能卡死在干净环境上。
129
+ */
130
+ export declare function gateClear(read: FactSourceRead, plan: PreservePlan): ClearGate;
131
+ /** 恢复结果。**逐条核实**,不靠 `appendNew` 的"没抛就算成功"。 */
132
+ export interface PreserveRestoreResult {
133
+ readonly attempted: number;
134
+ readonly restored: number;
135
+ readonly missing: readonly string[];
136
+ }
137
+ /**
138
+ * 把保留集放回检索库 + 事实源。
139
+ *
140
+ * 走 `L1Store.appendNew` 而不是 `db.upsertL1`:前者**双写**(JSONL 事实源 + 检索库),
141
+ * 而此刻 `records/` 刚被 `archiveDerived()` 改名走,只写检索库会让这些记忆
142
+ * 再次成为"没有事实源副本"的孤儿 —— 下一次重建就再也判不出它们了。
143
+ */
144
+ export declare function restorePreserved(l1: {
145
+ appendNew: (records: MemoryRecord[]) => Promise<void>;
146
+ getByIds: (ids: string[]) => MemoryRecord[];
147
+ }, plan: PreservePlan): Promise<PreserveRestoreResult>;
148
+ /** 面向日志/状态栏的一行摘要。 */
149
+ export declare function describePlan(plan: PreservePlan): string;
@@ -0,0 +1,206 @@
1
+ /**
2
+ * 保留式重建:清空 L1 前判定**哪些记忆不可能被 L0 重蒸馏出来**,并把这些保住。
3
+ *
4
+ * ## 为什么需要这个模块
5
+ *
6
+ * `RebuildController.prepare()` 的语义是"清空 L1 检索库 → 从 L0 全量重蒸馏"。
7
+ * 这个语义对**由 L0 蒸馏而来**的记忆是自洽的(清掉也能再造一遍),但对
8
+ * **不由 L0 派生**的记忆是破坏性的:`memory_add` / `memory_import` 写进来的
9
+ * 外部记忆(其他 AI 工具导出的记忆包)在任何会话日志里都没有对应位置,
10
+ * 重建不会再造出它们。实测(`_analysis_output` 探针,2026-09-17)这类记录
11
+ * 当时有 19 条存活,涉及 `__manual__` 与若干外部导入场景名。
12
+ *
13
+ * ## 判据为什么是 `source_message_ids`,而不是"有没有锚点"
14
+ *
15
+ * 计划书原稿写的是"无来源记忆(**导入类 / 无锚点类**)",但实测推翻了后半句:
16
+ *
17
+ * - 检索库 `l1_records` **没有 `source_message_ids` 列**(见 `types.ts:224` 的
18
+ * 既有注释"JSONL 事实源保留;检索库不存该列"),所以判据只能来自**事实源**;
19
+ * - 检索库里 `dsh_source_anchors` 的命中数是 **0/740** —— task_31 落地的锚点
20
+ * 写入**尚未产出任何一行**(没有新的蒸馏发生过)。把"无锚点"当判据会把
21
+ * **全部 740 条**都判成"要保留",重建直接退化成空操作。
22
+ *
23
+ * 所以判据取事实源里的 `source_message_ids`:非空 ⇒ 该记忆的位置在 L0 日志里
24
+ * 有据可查、重建能再造;空/缺失 ⇒ 造不出来,**必须保留**。
25
+ *
26
+ * ## 为什么必须取"事实源 ∩ 检索库"
27
+ *
28
+ * 事实源是**只增不改**的历史(合并/更新产出新 id 后,旧行仍留在 JSONL)。
29
+ * 实测:事实源 1,694 个唯一 id,检索库 740 个,`事实源 ∩ 检索库 == 检索库`
30
+ * (即检索库是事实源的真子集,反向差集为 0)。其中**已从检索库退场、但没有
31
+ * `source_message_ids` 的有 25 条** —— 按事实源无脑恢复会**复活这 25 条已删记录**。
32
+ * 故保留集 = 事实源无来源 **且** 检索库仍存在,两个条件缺一不可。
33
+ *
34
+ * ## 静默缺席的处理
35
+ *
36
+ * 事实源读不出来时,"没有记录需要保留"与"不知道哪些记录需要保留"长得一模一样。
37
+ * 后者若按前者处理,重建就**静默退化**成原来的破坏性行为。故 `gateClear()` 在
38
+ * 事实源不可读(或可读但零记录)**且检索库非空**时**拒绝放行**,而不是放行。
39
+ */
40
+ import { readdir } from 'node:fs/promises';
41
+ import { readJsonl } from '../util/io.js';
42
+ /** 判定单条记录是否可由 L0 重蒸馏出来(事实源侧判据)。 */
43
+ export function isRederivable(record) {
44
+ const src = record.source_message_ids;
45
+ return Array.isArray(src) && src.length > 0;
46
+ }
47
+ /**
48
+ * 读取事实源目录(`<dataDir>/records/*.jsonl`)。
49
+ *
50
+ * 只读 `*.jsonl` 顶层文件:`archiveDerived()` 把旧目录整体改名为
51
+ * `records.bak.<ts>`,它是**兄弟目录**不是文件,不会被 glob 命中。
52
+ * **必须在 `archiveDerived()` 之前调用** —— 归档之后事实源就搬走了。
53
+ */
54
+ export async function readFactSource(recordsDir) {
55
+ let names;
56
+ try {
57
+ names = (await readdir(recordsDir)).filter((n) => n.endsWith('.jsonl'));
58
+ }
59
+ catch (err) {
60
+ const code = err?.code;
61
+ return {
62
+ ok: false,
63
+ records: [],
64
+ files: 0,
65
+ reason: code === 'ENOENT' ? 'dir-missing' : 'read-error',
66
+ detail: err instanceof Error ? err.message : String(err),
67
+ };
68
+ }
69
+ // 同名 id 后者胜:JSONL 是追加日志,同日文件里后写的是更新形态。
70
+ const byId = new Map();
71
+ for (const name of names) {
72
+ try {
73
+ const rows = await readJsonl(`${recordsDir.replace(/[\\/]+$/, '')}/${name}`);
74
+ for (const r of rows) {
75
+ if (!r || typeof r.id !== 'string' || !r.id)
76
+ continue;
77
+ byId.set(r.id, r);
78
+ }
79
+ }
80
+ catch (err) {
81
+ return {
82
+ ok: false,
83
+ records: [],
84
+ files: names.length,
85
+ reason: 'read-error',
86
+ detail: `${name}: ${err instanceof Error ? err.message : String(err)}`,
87
+ };
88
+ }
89
+ }
90
+ return { ok: true, records: [...byId.values()], files: names.length };
91
+ }
92
+ /**
93
+ * 出保留计划。纯函数:输入事实源记录 + 检索库现存记录,输出要保留什么。
94
+ *
95
+ * 两个方向都保守:事实源说"这条没来源"要留,**检索库里查不到出处**也要留
96
+ * (`unprovenanced`)—— 判据缺失时留,而不是丢。
97
+ *
98
+ * `liveDbRecords` 由调用方从**检索库**取(它没有 `source_message_ids` 列,
99
+ * 只用来回答"这条还在不在"),恢复时以检索库副本为准 —— 那是当前检索态。
100
+ */
101
+ export function planPreserve(factRecords, liveDbRecords) {
102
+ const live = new Map();
103
+ for (const r of liveDbRecords)
104
+ if (r && typeof r.id === 'string')
105
+ live.set(r.id, r);
106
+ const preserved = [];
107
+ let rederivable = 0;
108
+ let retiredNoSource = 0;
109
+ const seen = new Set();
110
+ for (const f of factRecords) {
111
+ if (typeof f?.id !== 'string' || !f.id)
112
+ continue;
113
+ seen.add(f.id);
114
+ if (isRederivable(f)) {
115
+ // 有来源但已退场(合并掉了)的不必计数——重建会按 L0 再造。
116
+ if (live.has(f.id))
117
+ rederivable++;
118
+ continue;
119
+ }
120
+ const current = live.get(f.id);
121
+ if (!current) {
122
+ retiredNoSource++;
123
+ continue;
124
+ }
125
+ preserved.push({
126
+ record: current,
127
+ reason: String(current.content ?? '').trim() ? 'no-source' : 'empty-content',
128
+ });
129
+ }
130
+ // 检索库里有、事实源里没有 ⇒ 来源无从证明,一律保留(见 PreserveReason 注释)。
131
+ for (const [id, r] of live) {
132
+ if (seen.has(id))
133
+ continue;
134
+ preserved.push({ record: r, reason: 'unprovenanced' });
135
+ }
136
+ preserved.sort((a, b) => a.record.id.localeCompare(b.record.id));
137
+ return {
138
+ preserved,
139
+ rederivable,
140
+ retiredNoSource,
141
+ factCount: factRecords.length,
142
+ dbCount: live.size,
143
+ };
144
+ }
145
+ /**
146
+ * 清空前的守门判定。
147
+ *
148
+ * 拒绝而不是放行,是因为"没有需要保留的"与"不知道有没有需要保留的"在数据上
149
+ * 无法区分(见文件头「静默缺席的处理」)。检索库为空时不存在可失去的记忆,
150
+ * 故那一路照常放行 —— 不把功能卡死在干净环境上。
151
+ */
152
+ export function gateClear(read, plan) {
153
+ if (plan.dbCount === 0) {
154
+ return { allowed: true, code: 'empty-db', note: '检索库为空,无可保留项' };
155
+ }
156
+ if (!read.ok) {
157
+ const why = read.reason === 'dir-missing' ? '事实源目录不存在' : `事实源读取失败(${read.detail ?? '未知原因'})`;
158
+ return {
159
+ allowed: false,
160
+ code: 'source-missing',
161
+ note: `${why},而检索库有 ${plan.dbCount} 条记忆 —— 无法判定其中哪些不是从 L0 蒸馏来的,` +
162
+ '已中止重建且**未清空任何数据**。请先恢复 records/ 事实源(或从 snapshots/ 的快照找回)后重试。',
163
+ };
164
+ }
165
+ if (read.records.length === 0) {
166
+ return {
167
+ allowed: false,
168
+ code: 'source-empty',
169
+ note: `事实源可读但一条记录都没有,而检索库有 ${plan.dbCount} 条记忆 —— 事实源与检索库不一致,` +
170
+ '已中止重建且**未清空任何数据**。请检查 records/ 是否被清空或迁移过。',
171
+ };
172
+ }
173
+ const keep = plan.preserved.length;
174
+ return {
175
+ allowed: true,
176
+ code: 'ok',
177
+ note: keep > 0
178
+ ? `保留 ${keep} 条无 L0 来源的记忆(导入/手工写入),重建只重造 ${plan.rederivable} 条有来源的记忆`
179
+ : `无无来源记忆需要保留,重建重造 ${plan.rederivable} 条有来源的记忆`,
180
+ };
181
+ }
182
+ /**
183
+ * 把保留集放回检索库 + 事实源。
184
+ *
185
+ * 走 `L1Store.appendNew` 而不是 `db.upsertL1`:前者**双写**(JSONL 事实源 + 检索库),
186
+ * 而此刻 `records/` 刚被 `archiveDerived()` 改名走,只写检索库会让这些记忆
187
+ * 再次成为"没有事实源副本"的孤儿 —— 下一次重建就再也判不出它们了。
188
+ */
189
+ export async function restorePreserved(l1, plan) {
190
+ const ids = plan.preserved.map((p) => p.record.id);
191
+ if (ids.length === 0)
192
+ return { attempted: 0, restored: 0, missing: [] };
193
+ await l1.appendNew(plan.preserved.map((p) => p.record));
194
+ const back = new Set(l1.getByIds(ids).map((r) => r.id));
195
+ const missing = ids.filter((id) => !back.has(id));
196
+ return { attempted: ids.length, restored: ids.length - missing.length, missing };
197
+ }
198
+ /** 面向日志/状态栏的一行摘要。 */
199
+ export function describePlan(plan) {
200
+ const byReason = new Map();
201
+ for (const p of plan.preserved)
202
+ byReason.set(p.reason, (byReason.get(p.reason) ?? 0) + 1);
203
+ const parts = [...byReason].map(([r, n]) => `${r} ${n}`);
204
+ return (`保留 ${plan.preserved.length} 条无来源记忆${parts.length ? `(${parts.join(' / ')})` : ''};` +
205
+ `可重蒸馏 ${plan.rederivable} 条;事实源 ${plan.factCount} 条(其中无来源但已退场 ${plan.retiredNoSource} 条,不恢复)`);
206
+ }
@@ -3,19 +3,28 @@
3
3
  *
4
4
  * 语义(CONTEXT.md「重建」):
5
5
  * - L0 永不改动;旧派生层归档保留(records/ scenes/ persona-*.md → *.bak.<ts>,不硬删);
6
- * - 检索库 L1 三表清空、checkpoint 原地重置;
6
+ * - 检索库 L1 清空、checkpoint 原地重置;
7
7
  * - 统一按 auto 档、按会话分块重蒸馏;分块经 runner 的低优先级队列让位于正常轮次;
8
8
  * - 收尾强制一轮 L2(各族残余记录)+ L3(重建后 hasPersona=false → 冷启动触发)。
9
9
  *
10
+ * **保留式清空(task_8c)**:"清空 + 重蒸馏"对**由 L0 派生**的记忆自洽,对
11
+ * **不由 L0 派生**的记忆则是破坏性的:`memory_add` / `memory_import` 写进来的
12
+ * 外部记忆在会话日志里没有对应位置,清掉就再也造不出来。故 `prepare()` 的固定顺序为
13
+ * **读事实源 → 判保留集 → 过闸门 → 快照 → 归档 → 清空 → 放回保留集**;
14
+ * 判据与实测依据见 `rebuild-preserve.ts` 文件头。事实源读不到时**拒绝清空** ——
15
+ * 判不出该保留什么,放行等于静默退化成破坏性重建。
16
+ *
10
17
  * 失败语义:准备/归档任一步失败 → phase=failed,绝不拖垮宿主;
11
18
  * 单块蒸馏失败继续下一块(消息留在未蒸馏缓冲,下轮对话/重启补跑自愈)。
12
19
  */
13
20
  import { promises as fs } from 'node:fs';
14
21
  import * as path from 'node:path';
15
22
  import { resolveDataDir } from '../config.js';
23
+ import { snapshotBeforeClear } from '../store/l1-snapshot.js';
16
24
  import { errDetail } from '../util/filelog.js';
17
25
  import { runSceneConsolidation } from './l2.js';
18
26
  import { runPersona } from './l3.js';
27
+ import { describePlan, gateClear, planPreserve, readFactSource, restorePreserved } from './rebuild-preserve.js';
19
28
  import { effectiveCfg } from './runner.js';
20
29
  export function groupL0Sessions(records) {
21
30
  const bySession = new Map();
@@ -62,6 +71,7 @@ function idleStatus() {
62
71
  finishedAt: null,
63
72
  error: null,
64
73
  archiveNote: null,
74
+ preserveNote: null,
65
75
  };
66
76
  }
67
77
  /** 时间戳后缀(归档命名,秒级防撞)。 */
@@ -149,12 +159,35 @@ export class RebuildController {
149
159
  return;
150
160
  }
151
161
  this.status.total = this.chunks.length;
152
- // 归档旧派生层(改名不硬删;任一失败即终止——半清半留会破坏"全量重导"语义)
162
+ const dataDir = resolveDataDir(this.cfg);
163
+ // ── ① 读事实源 + 判定保留集(必须在 archiveDerived 之前:归档会把 records/ 搬走)──
164
+ const factRead = await readFactSource(path.join(dataDir, 'records'));
165
+ const plan = planPreserve(factRead.records, this.stores.l1.all());
166
+ // ── ② 过闸门:判不出该保留什么时**拒绝清空**(见 rebuild-preserve.ts 文件头)──
167
+ const gate = gateClear(factRead, plan);
168
+ if (!gate.allowed) {
169
+ this.logger.error(`[memory] 重建已中止:${gate.note}`);
170
+ this.finish('failed', gate.note);
171
+ return;
172
+ }
173
+ // ── ③ 先快照后清空:快照失败即终止(此刻还没清任何东西,中止是零代价的)──
174
+ const snap = await snapshotBeforeClear(this.db, dataDir, 'pre-rebuild');
175
+ this.logger.info(`[memory] 重建前快照已建:${path.basename(snap.dir)}(${snap.manifest.sections.records.count} 条,哈希 ${snap.manifest.sections.records.hash.slice(0, 8)})`);
176
+ // ── ④ 归档旧派生层(改名不硬删;任一失败即终止——半清半留会破坏"全量重导"语义)──
153
177
  const archiveNote = await this.archiveDerived();
154
178
  this.status.archiveNote = archiveNote ?? null;
155
- // 清检索库 + 重置 checkpoint;归档后重建空目录(records/ 由 appendNew 自动重建)
179
+ // ── ⑤ 清检索库 + 重置 checkpoint;归档后重建空目录(records/ 由 appendNew 自动重建)──
156
180
  if (!this.db.clearL1())
157
181
  throw new Error('L1 检索库清空失败');
182
+ // ── ⑥ 把无来源记忆放回:清空后立刻、且在首块蒸馏之前 ——
183
+ // 放回后它们在检索库里,后续 L1 去重候选就能看见它们,不会重复造出新副本。
184
+ const restored = await restorePreserved(this.stores.l1, plan);
185
+ if (restored.missing.length > 0) {
186
+ // 不静默:保留集里没回来的 id 必须点名(它们在快照里,可手工找回)
187
+ this.logger.error(`[memory] 保留集恢复不完整:${restored.restored}/${restored.attempted} 条,缺失 ${restored.missing.join(', ')}` +
188
+ `(缺失项仍在本轮快照 ${path.basename(snap.dir)} 中)`);
189
+ }
190
+ this.status.preserveNote = describePlan(plan);
158
191
  this.stores.state.reset();
159
192
  await this.stores.state.save();
160
193
  await Promise.all([
@@ -164,7 +197,7 @@ export class RebuildController {
164
197
  this.stores.persona.work.init(),
165
198
  ]);
166
199
  this.status.phase = 'distilling';
167
- this.logger.info(`[memory] 重建准备完成(归档:${archiveNote ?? '无旧产物'},${this.chunks.length} 个会话块)`);
200
+ this.logger.info(`[memory] 重建准备完成(归档:${archiveNote ?? '无旧产物'},保留:${this.status.preserveNote},${this.chunks.length} 个会话块)`);
168
201
  this.scheduleChunk(0);
169
202
  }
170
203
  catch (err) {
@@ -0,0 +1,102 @@
1
+ import type { EvidenceSource } from '../store/evidence-source.js';
2
+ import type { MemoryLogger } from '../types.js';
3
+ import { type MemoryVerdict, type ReconcileDeps, type ReconcileInput, type ReconcileState } from './reconcile.js';
4
+ /**
5
+ * 三重上限 + 调用数上限。**每一项都是硬闸门**:超了就裁掉并在计划里写清楚裁了什么。
6
+ */
7
+ export interface ReconcileBudget {
8
+ /** 单次运行最多核对多少条记忆。 */
9
+ maxRecords: number;
10
+ /** 单条记忆最多用几个锚点(锚点多 = 证据长 = 贵)。 */
11
+ maxAnchorsPerMemory: number;
12
+ /** 单条记忆的证据文本上限(字符)。 */
13
+ maxEvidenceChars: number;
14
+ /** 单次运行最多多少次模型调用(有锚点且未完成的条目才会产生调用)。 */
15
+ maxCalls: number;
16
+ }
17
+ export declare const DEFAULT_RECONCILE_BUDGET: ReconcileBudget;
18
+ /** 预估结果(展示给人看的就是这个)。 */
19
+ export interface ReconcileEstimate {
20
+ /** 输入里一共有多少条记忆。 */
21
+ total: number;
22
+ /** 其中带锚点(会花钱)的条数。 */
23
+ billable: number;
24
+ /** 没有锚点(免费,直接跳过)的条数。 */
25
+ skippedNoAnchor: number;
26
+ /** 因上限被裁掉的条数。 */
27
+ trimmedByRecords: number;
28
+ /** 因 `maxCalls` 被裁掉的条数。 */
29
+ trimmedByCalls: number;
30
+ /** 会被使用的锚点总数(裁剪后)。 */
31
+ anchors: number;
32
+ /** 已在上次运行中完成、本次不会重复计费的条数。 */
33
+ alreadyDone: number;
34
+ /** 输入字符上界(记忆正文 + 证据上限)。 */
35
+ estInputChars: number;
36
+ /** 输入 token 上界(按既有 CHARS_PER_TOKEN 口径,与官方同式)。 */
37
+ estInputTokens: number;
38
+ /** 模型调用次数上界。 */
39
+ estCalls: number;
40
+ }
41
+ /** 预估 + 裁剪后的执行计划。**只有它能进 `executeReconcile`。** */
42
+ export interface ReconcilePlan {
43
+ budget: ReconcileBudget;
44
+ estimate: ReconcileEstimate;
45
+ /** 裁剪后的待核对条目。 */
46
+ memories: readonly ReconcileInput[];
47
+ /** 人类可读的预估说明(面板/CLI 直接展示)。 */
48
+ summary: string;
49
+ }
50
+ /** 断点续跑状态文件。 */
51
+ export interface ReconcileRunState {
52
+ version: 1;
53
+ runId: string;
54
+ startedAt: number;
55
+ updatedAt: number;
56
+ /** 已完成条目键:`<memoryId>:<内容哈希前 8 位>`。 */
57
+ done: string[];
58
+ }
59
+ /** 内容哈希前 8 位:**正文一变就必须重核**(见模块头注释)。 */
60
+ export declare function contentHash(text: string): string;
61
+ /** 续跑键:`(memoryId, 内容哈希)`。 */
62
+ export declare function resumeKey(memory: Pick<ReconcileInput, 'id' | 'text'>): string;
63
+ /** 状态文件路径(与 `pendingPathFor` / `state.json` 同目录)。 */
64
+ export declare function reconcileStatePathFor(dataDir: string): string;
65
+ /** 读状态;**文件损坏/版本不符一律当作空状态**,不抛(续跑状态坏了不该阻止运行)。 */
66
+ export declare function loadRunState(file: string): Promise<ReconcileRunState | undefined>;
67
+ /**
68
+ * 预估并裁剪。
69
+ *
70
+ * @param memories - 全部候选记忆。
71
+ * @param budget - 三重上限。
72
+ * @param doneKeys - 上次已完成、本次要跳过的键。
73
+ * @returns 预估 + 计划。**这是唯一能产出 `ReconcilePlan` 的入口。**
74
+ */
75
+ export declare function planReconcile(memories: readonly ReconcileInput[], budget?: ReconcileBudget, doneKeys?: readonly string[]): ReconcilePlan;
76
+ /** 预估说明(先给人看)。**必须写清裁掉了什么**——只报"要跑多少"会让人以为跑全了。 */
77
+ export declare function renderEstimate(estimate: ReconcileEstimate, budget: ReconcileBudget): string;
78
+ export interface ExecuteReconcileOptions {
79
+ signal?: AbortSignal;
80
+ /** 状态文件路径;不传则不落盘(纯内存运行)。 */
81
+ stateFile?: string;
82
+ logger?: MemoryLogger;
83
+ onProgress?: (done: number, total: number, verdict: MemoryVerdict) => void;
84
+ }
85
+ export interface ExecuteReconcileResult {
86
+ verdicts: MemoryVerdict[];
87
+ report: string;
88
+ counts: Record<ReconcileState, number>;
89
+ /** 本次是否因中断提前结束(续跑时从状态文件接着来)。 */
90
+ interrupted: boolean;
91
+ state?: ReconcileRunState;
92
+ }
93
+ /**
94
+ * 执行计划(只读;唯一的副作用是写**续跑状态文件**,不是记忆库)。
95
+ *
96
+ * 逐条执行、**每条完成后原子落盘**:进程被杀也能续跑,已完成的不重复计费。
97
+ */
98
+ export declare function executeReconcile(deps: ReconcileDeps, plan: ReconcilePlan, opts?: ExecuteReconcileOptions): Promise<ExecuteReconcileResult>;
99
+ /** 清空续跑状态(显式动作:下次运行会重核所有条目,**会重复计费**)。 */
100
+ export declare function resetRunState(file: string): Promise<void>;
101
+ /** 供装配层使用:把证据面与 judge 组装成 deps。 */
102
+ export declare function makeReconcileDeps(evidence: EvidenceSource, judge: ReconcileDeps['judge'], logger?: MemoryLogger): ReconcileDeps;