feiyu-memory 0.0.0-stage → 4.0.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,572 @@
1
+ /**
2
+ * 档案层 —— `.dshpack` 的读写、校验、迁移。
3
+ *
4
+ * 这一层是**纯函数**:只在「文件名 → 文本」的映射上工作,不碰磁盘。
5
+ * 真正的 IO 由 host 层负责(走 DSH 的 fs 服务或 node:fs)。
6
+ * 这样核心逻辑可以完全脱机测试,也保证档案格式与运行环境解耦。
7
+ */
8
+
9
+ import { zipSync, unzipSync } from './zip.mjs';
10
+
11
+ /** 当前档案格式版本。改格式必须同时加迁移函数。 */
12
+ export const SCHEMA_VERSION = 1;
13
+
14
+ export const MANIFEST_FILE = 'manifest.json';
15
+ export const PERSONA_FILE = 'persona.md';
16
+ export const RELATIONSHIP_FILE = 'relationship.json';
17
+ export const LEXICON_FILE = 'lexicon.json';
18
+ export const DIARY_DIR = 'diary';
19
+
20
+ /** 人格锚点的分界注释 —— 锚点块永不被自动改写(SPEC §1.2) */
21
+ export const ANCHOR_BEGIN = '<!-- feiyu:anchor:begin -->';
22
+ export const ANCHOR_END = '<!-- feiyu:anchor:end -->';
23
+
24
+ /** 自动学到的认知写在这一行之后 */
25
+ export const LEARNED_HEADING = '## 我后来想明白的';
26
+
27
+ /**
28
+ * @typedef {object} DiaryEntry
29
+ * @property {string} id
30
+ * @property {string} date YYYY-MM-DD
31
+ * @property {string} text 第一人称
32
+ * @property {string[]} tags
33
+ * @property {'failure'|'win'|'chat'|'disclosure'|'grievance'} kind
34
+ * @property {number} salience 0~1
35
+ * @property {boolean} [resolved] 仅 failure 有意义
36
+ * @property {string} [session]
37
+ * @property {number} created_at
38
+ * @property {string[]} [keywords]
39
+ */
40
+
41
+ /**
42
+ * @typedef {object} Trait
43
+ * @property {string} key
44
+ * @property {string} value
45
+ * @property {number} confidence 被观察到几次
46
+ * @property {number} salience
47
+ * @property {number} last_reinforced
48
+ */
49
+
50
+
51
+ /**
52
+ * @typedef {object} Archive
53
+ * @property {object} manifest
54
+ * @property {string} persona
55
+ * @property {object} relationship
56
+ * @property {object} lexicon
57
+ * @property {DiaryEntry[]} diary
58
+ */
59
+
60
+ /* ───────────────────────── 默认值 ───────────────────────── */
61
+
62
+ /**
63
+ * @param {number} [now]
64
+ * @param {string} [persona] 出厂人格正文
65
+ * @returns {Archive}
66
+ */
67
+ export function createEmptyArchive(now = Date.now(), persona = '') {
68
+ return {
69
+ manifest: {
70
+ schema_version: SCHEMA_VERSION,
71
+ created_at: now,
72
+ updated_at: now,
73
+ last_seen_at: 0,
74
+ session_count: 0,
75
+ diary_count: 0,
76
+ generator: 'feiyu-memory',
77
+ /**
78
+ * **墓碑**:被删掉的日记 id,只增不减。
79
+ *
80
+ * 为什么需要它:档案被很多个会话同时打开,落盘时整份重写。
81
+ * 落盘前的三路合并要能区分"别人刚加的"和"我们删掉的"——
82
+ * 光靠"加载时的基线"不够:**本会话自己新增、又自己删掉的条目
83
+ * 不在基线里**,会被当成"别人刚加的"捞回来(`burn()` 就是这么坏的)。
84
+ *
85
+ * 所以删除必须留个记号。老档案没有这个字段 → 当空数组处理。
86
+ */
87
+ deleted_ids: /** @type {string[]} */ ([]),
88
+ },
89
+ persona: ensurePersonaStructure(persona),
90
+ relationship: {
91
+ familiarity: 0,
92
+ nickname: '',
93
+ traits: /** @type {Trait[]} */ ([]),
94
+ },
95
+ lexicon: { items: /** @type {Array<object>} */ ([]) },
96
+ diary: /** @type {DiaryEntry[]} */ ([]),
97
+ };
98
+ }
99
+
100
+ /* ───────────────────────── persona 结构 ───────────────────────── */
101
+
102
+ /**
103
+ * 保证人格文本有锚点分界。已经有就原样返回。
104
+ * 用户的自由文本一律进锚点块(他写的就是"出厂设定"),
105
+ * 自动学到的东西只能写在 `## 我后来想明白的` 之后。
106
+ *
107
+ * @param {string} text
108
+ * @returns {string}
109
+ */
110
+ export function ensurePersonaStructure(text) {
111
+ const body = typeof text === 'string' ? text.trim() : '';
112
+ if (body.includes(ANCHOR_BEGIN) && body.includes(ANCHOR_END)) return body;
113
+ // 末尾不留空行 —— 否则这个函数不是幂等的,每次读档案都会多出一个换行
114
+ return [ANCHOR_BEGIN, '', body, '', ANCHOR_END, '', LEARNED_HEADING, ''].join('\n').trimEnd();
115
+ }
116
+
117
+ /**
118
+ * 拆出锚点与已学部分。
119
+ * @param {string} text
120
+ * @returns {{anchor: string, learned: string, structured: boolean}}
121
+ */
122
+ export function splitPersona(text) {
123
+ const src = typeof text === 'string' ? text : '';
124
+ const begin = src.indexOf(ANCHOR_BEGIN);
125
+ const end = src.indexOf(ANCHOR_END);
126
+ if (begin < 0 || end < 0 || end < begin) {
127
+ return { anchor: src.trim(), learned: '', structured: false };
128
+ }
129
+ const anchor = src.slice(begin + ANCHOR_BEGIN.length, end).trim();
130
+ const rest = src.slice(end + ANCHOR_END.length);
131
+ const headingAt = rest.indexOf(LEARNED_HEADING);
132
+ const learned = headingAt >= 0 ? rest.slice(headingAt + LEARNED_HEADING.length).trim() : rest.trim();
133
+ return { anchor, learned, structured: true };
134
+ }
135
+
136
+ /* ───────────────────────── 序列化 ───────────────────────── */
137
+
138
+ /**
139
+ * 把档案拆成 zip 条目。**diary 按月份分文件**,追加安全、坏一行只丢一行。
140
+ *
141
+ * @param {Archive} archive
142
+ * @returns {Array<{name: string, data: string}>}
143
+ */
144
+ export function archiveToFiles(archive) {
145
+ const files = [];
146
+
147
+ const manifest = { ...archive.manifest };
148
+ manifest.schema_version = SCHEMA_VERSION;
149
+ manifest.diary_count = archive.diary.length;
150
+ files.push({ name: MANIFEST_FILE, data: JSON.stringify(manifest, null, 2) + '\n' });
151
+
152
+ files.push({ name: PERSONA_FILE, data: ensurePersonaStructure(archive.persona) + '\n' });
153
+ files.push({ name: RELATIONSHIP_FILE, data: JSON.stringify(archive.relationship, null, 2) + '\n' });
154
+ files.push({ name: LEXICON_FILE, data: JSON.stringify(archive.lexicon, null, 2) + '\n' });
155
+
156
+ const byMonth = new Map();
157
+ for (const entry of archive.diary) {
158
+ const month = (entry.date ?? '').slice(0, 7) || 'unknown';
159
+ const list = byMonth.get(month) ?? [];
160
+ list.push(entry);
161
+ byMonth.set(month, list);
162
+ }
163
+ for (const [month, list] of [...byMonth].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
164
+ const data = list.map((e) => JSON.stringify(e)).join('\n') + '\n';
165
+ files.push({ name: `${DIARY_DIR}/${month}.jsonl`, data });
166
+ }
167
+
168
+ return files;
169
+ }
170
+
171
+ /**
172
+ * 从 zip 条目还原档案。**永不抛错** —— 坏数据降级成 warnings,
173
+ * 因为"记忆坏了不能挡住聊天"(SPEC §10)。
174
+ *
175
+ * @param {Array<{name: string, text: () => string}>} entries
176
+ * @param {{now?: number}} [options]
177
+ * @returns {{archive: Archive, warnings: string[]}}
178
+ */
179
+ export function archiveFromFiles(entries, options = {}) {
180
+ const now = options.now ?? Date.now();
181
+ /** @type {string[]} */
182
+ const warnings = [];
183
+ const byName = new Map();
184
+ for (const e of entries) {
185
+ try {
186
+ byName.set(e.name, e.text());
187
+ } catch (err) {
188
+ warnings.push(`读不出 ${e.name}:${err instanceof Error ? err.message : String(err)}`);
189
+ }
190
+ }
191
+
192
+ /** @type {any} */
193
+ let manifest;
194
+ try {
195
+ manifest = JSON.parse(byName.get(MANIFEST_FILE) ?? '{}');
196
+ } catch {
197
+ manifest = {};
198
+ warnings.push('manifest.json 解析失败,按空档案处理');
199
+ }
200
+
201
+ const version = Number(manifest.schema_version);
202
+ if (!Number.isFinite(version)) {
203
+ warnings.push('manifest 缺少 schema_version,按当前版本处理');
204
+ } else if (version > SCHEMA_VERSION) {
205
+ warnings.push(
206
+ `档案版本 ${version} 比本插件(${SCHEMA_VERSION})新,可能读不全新字段;只读不写更安全`,
207
+ );
208
+ } else if (version < SCHEMA_VERSION) {
209
+ warnings.push(`档案版本 ${version} 需要迁移到 ${SCHEMA_VERSION}`);
210
+ }
211
+
212
+ const persona = readText(byName, PERSONA_FILE, '');
213
+ const relationship = readJson(byName, RELATIONSHIP_FILE, warnings, {
214
+ familiarity: 0,
215
+ nickname: '',
216
+ traits: [],
217
+ });
218
+ const lexicon = readJson(byName, LEXICON_FILE, warnings, { items: [] });
219
+
220
+ /** @type {DiaryEntry[]} */
221
+ const diary = [];
222
+ let dropped = 0;
223
+ for (const [name, text] of byName) {
224
+ if (!name.startsWith(`${DIARY_DIR}/`) || !name.endsWith('.jsonl')) continue;
225
+ for (const line of text.split('\n')) {
226
+ const trimmed = line.trim();
227
+ if (trimmed === '') continue;
228
+ try {
229
+ const parsed = JSON.parse(trimmed);
230
+ if (parsed && typeof parsed.id === 'string' && typeof parsed.text === 'string') {
231
+ diary.push(normalizeDiaryEntry(parsed));
232
+ } else {
233
+ dropped++;
234
+ }
235
+ } catch {
236
+ dropped++;
237
+ }
238
+ }
239
+ }
240
+ if (dropped > 0) warnings.push(`日记里有 ${dropped} 行读不出来,已跳过`);
241
+
242
+ diary.sort((a, b) => a.created_at - b.created_at);
243
+
244
+ // ── 墓碑在这里也必须生效 ──
245
+ //
246
+ // 社区报的(这次比前两个严重):焚毁 / 忘掉一条之后,内存里没了,
247
+ // **磁盘上那份 jsonl 没被重写** —— 于是重启一读,删掉的内容全回来了。
248
+ //
249
+ // 为什么之前只在 mergeArchives 里过滤:那个函数是"磁盘 + 内存"的三路合并,
250
+ // 它需要墓碑来判断"这条是我删的还是别人加的"。但**加载这一步不经过它** ——
251
+ // 启动时直接 archiveFromFiles(entries),磁盘上有什么就读什么。
252
+ //
253
+ // 所以过滤要做两处:合并时一次,**读盘时也要一次**。
254
+ // (导入包依然是"先撤碑、再读",见 importPack —— 那是用户明确要把东西拿回来。)
255
+ const tombstones = new Set(
256
+ Array.isArray(manifest.deleted_ids) ? manifest.deleted_ids.filter((x) => typeof x === 'string') : [],
257
+ );
258
+ const visible = tombstones.size > 0 ? diary.filter((e) => !tombstones.has(e.id)) : diary;
259
+ const resurrected = diary.length - visible.length;
260
+ if (resurrected > 0) {
261
+ warnings.push(`按墓碑跳过 ${resurrected} 条已删除的日记(它们还躺在磁盘上)`);
262
+ }
263
+
264
+ return {
265
+ archive: {
266
+ manifest: {
267
+ schema_version: SCHEMA_VERSION,
268
+ created_at: Number(manifest.created_at) || now,
269
+ updated_at: Number(manifest.updated_at) || now,
270
+ last_seen_at: Number(manifest.last_seen_at) || 0,
271
+ session_count: Number(manifest.session_count) || 0,
272
+ diary_count: visible.length,
273
+ generator: typeof manifest.generator === 'string' ? manifest.generator : 'feiyu-memory',
274
+ // 墓碑必须活过一轮读写 —— 它靠的就是"删过的永不再来"。
275
+ // 这里漏了它,落盘一次墓碑就没了,删除立刻开始复活。
276
+ deleted_ids: Array.isArray(manifest.deleted_ids)
277
+ ? manifest.deleted_ids.filter((x) => typeof x === 'string')
278
+ : [],
279
+ },
280
+ persona: ensurePersonaStructure(persona),
281
+ relationship: sanitizeRelationship(relationship),
282
+ lexicon: { items: Array.isArray(lexicon.items) ? lexicon.items : [] },
283
+ diary: visible,
284
+ },
285
+ warnings,
286
+ };
287
+ }
288
+
289
+ /** 把一条日记规整成合法形状,缺字段补默认值 */
290
+ export function normalizeDiaryEntry(raw) {
291
+ // 'note' = **哪个肥鱼自己主动留下的**,不是固化模型生成的那种。
292
+ // 未知 kind 会退化成 'chat',所以加一个新值不会让老档案读不了 ——
293
+ // 不需要 schema 版本迁移。
294
+ const KINDS = new Set(['failure', 'win', 'chat', 'disclosure', 'grievance', 'note']);
295
+ return {
296
+ id: String(raw.id),
297
+ date: typeof raw.date === 'string' ? raw.date : new Date(Number(raw.created_at) || Date.now()).toISOString().slice(0, 10),
298
+ text: String(raw.text),
299
+ tags: Array.isArray(raw.tags) ? raw.tags.filter((t) => typeof t === 'string').slice(0, 8) : [],
300
+ kind: KINDS.has(raw.kind) ? raw.kind : 'chat',
301
+ salience: clamp01(raw.salience, 0.5),
302
+ ...(raw.kind === 'failure' ? { resolved: raw.resolved === true } : {}),
303
+ ...(typeof raw.session === 'string' ? { session: raw.session } : {}),
304
+ // 轮次 —— "续写同一条日记"靠它算窗口。
305
+ // 老条目没有这个字段,Number.isFinite(undefined) 是 false,所以它们永远不续写。
306
+ ...(Number.isFinite(raw.turn) ? { turn: Number(raw.turn) } : {}),
307
+ // 最后一次被追加的时间。created_at 不动,但排序看它 ——
308
+ // 否则刚续写的那条会按原始时间沉到下面去。
309
+ ...(Number.isFinite(raw.updated_at) ? { updated_at: Number(raw.updated_at) } : {}),
310
+ created_at: Number(raw.created_at) || Date.now(),
311
+ keywords: Array.isArray(raw.keywords) ? raw.keywords.filter((k) => typeof k === 'string').slice(0, 16) : [],
312
+ };
313
+ }
314
+
315
+ function sanitizeRelationship(raw) {
316
+ const traits = Array.isArray(raw?.traits)
317
+ ? raw.traits
318
+ .filter((t) => t && typeof t.key === 'string' && typeof t.value === 'string')
319
+ .map((t) => ({
320
+ key: t.key,
321
+ value: t.value,
322
+ // 产生这个印象的那个具体瞬间。没有它,注入区就读起来像"用户说明书"。
323
+ evidence: typeof t.evidence === 'string' ? t.evidence : '',
324
+ // 'user' = 我对他的印象;'self' = 我给自己定的规矩。两者不能混。
325
+ about: t.about === 'self' ? 'self' : 'user',
326
+ confidence: Number(t.confidence) || 1,
327
+ salience: clamp01(t.salience, 0.5),
328
+ last_reinforced: Number(t.last_reinforced) || Date.now(),
329
+ }))
330
+ : [];
331
+ // ⚠️ **这里不再解析 grievances。** 那个字段整个功能已经删除(2026-10-24)。
332
+ // 老档案里如果有这个字段,读盘时直接被忽略 —— 下一次写盘就没了。
333
+ return {
334
+ familiarity: clamp01(raw?.familiarity, 0),
335
+ nickname: typeof raw?.nickname === 'string' ? raw.nickname : '',
336
+ traits,
337
+ };
338
+ }
339
+
340
+ function readText(map, name, fallback) {
341
+ const v = map.get(name);
342
+ return typeof v === 'string' ? v : fallback;
343
+ }
344
+
345
+ function readJson(map, name, warnings, fallback) {
346
+ const text = map.get(name);
347
+ if (typeof text !== 'string') return fallback;
348
+ try {
349
+ const parsed = JSON.parse(text);
350
+ return parsed && typeof parsed === 'object' ? parsed : fallback;
351
+ } catch (err) {
352
+ warnings.push(`${name} 解析失败,用默认值顶上:${err instanceof Error ? err.message : String(err)}`);
353
+ return fallback;
354
+ }
355
+ }
356
+
357
+ function clamp01(v, fallback) {
358
+ const n = Number(v);
359
+ if (!Number.isFinite(n)) return fallback;
360
+ return Math.min(1, Math.max(0, n));
361
+ }
362
+
363
+ /* ───────────────────────── 打包 / 解包 ───────────────────────── */
364
+
365
+ /**
366
+ * 打包成 `.dshpack` 字节。默认压缩(日记是文本,压缩收益明显)。
367
+ * @param {Archive} archive
368
+ * @param {{compress?: boolean, date?: Date}} [options]
369
+ * @returns {Uint8Array}
370
+ */
371
+ export function packArchive(archive, options = {}) {
372
+ return zipSync(archiveToFiles(archive), { compress: options.compress !== false, date: options.date });
373
+ }
374
+
375
+ /**
376
+ * 解包 `.dshpack`。任何失败都变成 warning + 尽可能还原,不抛给调用方。
377
+ *
378
+ * @param {Uint8Array} bytes
379
+ * @param {{now?: number}} [options]
380
+ * @returns {{archive: Archive, warnings: string[], ok: boolean}}
381
+ */
382
+ export function unpackArchive(bytes, options = {}) {
383
+ let entries;
384
+ try {
385
+ entries = unzipSync(bytes);
386
+ } catch (err) {
387
+ return {
388
+ archive: createEmptyArchive(options.now),
389
+ warnings: [`档案解不开,已按空档案处理:${err instanceof Error ? err.message : String(err)}`],
390
+ ok: false,
391
+ };
392
+ }
393
+ const { archive, warnings } = archiveFromFiles(entries, options);
394
+ return { archive, warnings, ok: true };
395
+ }
396
+
397
+ /* ───────────────────────── 迁移 ───────────────────────── */
398
+
399
+ /**
400
+ * 迁移表:键是**目标版本**,值是把上一版升到该版的函数。
401
+ *
402
+ * 目前只有 v1,所以是空的。以后加 v2 就在这里写 `2: (archive) => {...}`,
403
+ * 并**同时**补一个测试。迁移必须是纯函数、幂等。
404
+ *
405
+ * @type {Record<number, (archive: any) => any>}
406
+ */
407
+ export const MIGRATIONS = {
408
+ // 2: (archive) => ({ ...archive, manifest: { ...archive.manifest, schema_version: 2 } }),
409
+ };
410
+
411
+ /**
412
+ * 把档案从它自己的版本升到 SCHEMA_VERSION。迁移函数缺失时**保留原档案并报警**,
413
+ * 绝不半途改坏用户的记忆。
414
+ *
415
+ * @param {Archive} archive
416
+ * @returns {{archive: Archive, warnings: string[]}}
417
+ */
418
+ export function migrateArchive(archive) {
419
+ const warnings = [];
420
+ let current = archive;
421
+ let version = Number(current.manifest?.schema_version) || 1;
422
+
423
+ if (version > SCHEMA_VERSION) {
424
+ warnings.push(`档案版本 ${version} 高于本插件支持的 ${SCHEMA_VERSION},不做迁移`);
425
+ return { archive: current, warnings };
426
+ }
427
+
428
+ while (version < SCHEMA_VERSION) {
429
+ const next = version + 1;
430
+ const step = MIGRATIONS[next];
431
+ if (typeof step !== 'function') {
432
+ warnings.push(`缺少从 v${version} 到 v${next} 的迁移,保留原档案`);
433
+ break;
434
+ }
435
+ try {
436
+ current = step(current);
437
+ current.manifest = { ...current.manifest, schema_version: next };
438
+ version = next;
439
+ } catch (err) {
440
+ warnings.push(`从 v${version} 迁移到 v${next} 失败:${err instanceof Error ? err.message : String(err)}`);
441
+ break;
442
+ }
443
+ }
444
+
445
+ return { archive: current, warnings };
446
+ }
447
+
448
+ /**
449
+ * 两个档案的合并策略:把 incoming 的条目并进 base,按 id 去重,base 优先。
450
+ *
451
+ * ── 传了 `baselineIds` 就是**三路合并**,这是并发写入的正解 ──
452
+ *
453
+ * 场景:同一份档案被很多个会话同时打开(实测 10 个),每个会话启动时读进内存、
454
+ * 落盘时**整份重写**。不合并的话,谁后落盘谁赢,另一个刚写的条目被静默抹掉。
455
+ *
456
+ * 但只做并集(`base ∪ incoming`)会**把删除弄坏** —— 落盘前读一次磁盘,
457
+ * 刚删掉的条目又回来了。三条测试当场抓到。
458
+ *
459
+ * 所以需要第三个信息:**这个会话加载时磁盘上有什么**(`baselineIds`)。
460
+ * 有了它,"谁删了什么"就能推出来:
461
+ *
462
+ * | 内存 | 磁盘 | 基线 | 含义 | 处置 |
463
+ * |---|---|---|---|---|
464
+ * | 有 | 有 | — | 两边都有 | 留内存的(它对已知条目最新) |
465
+ * | 有 | 无 | 有 | **别人删了它** | 丢掉,别复活 |
466
+ * | 有 | 无 | 无 | 我们自己刚加的 | 留着 |
467
+ * | 无 | 有 | 有 | **我们自己删了它** | 不加回来 |
468
+ * | 无 | 有 | 无 | **别人刚加的** | 并进来 |
469
+ * | 无 | 无 | — | 都删了 | 没有 |
470
+ *
471
+ * ⚠️ 光有基线**不够**:本会话自己新增、又自己删掉的条目不在基线里,
472
+ * 按上表会被当成"别人刚加的"捞回来 —— 面板上那个「忘掉」和「焚毁」
473
+ * 就是这样坏掉的,三条测试当场抓到。
474
+ *
475
+ * 所以还要一张**墓碑**(`manifest.deleted_ids`):删过的 id 只增不减,
476
+ * **夹在两边的墓碑并集里就永远不加回来**。它同时管住了跨会话的删除。
477
+ *
478
+ * 不传 `baselineIds` 时退回简单并集(老行为),给不关心删除的调用方用。
479
+ *
480
+ * ⚠️ **调用方有责任保证磁盘真的读到了**:磁盘读失败时如果还传一个空基线,
481
+ * 上表第一行会全部退化成"别人删了它",**整个档案会被清空**。
482
+ * 读不到就别调这个函数。
483
+ *
484
+ * @param {object} base 内存里的档案(优先)
485
+ * @param {object} incoming 磁盘上的档案
486
+ * @param {object} [options]
487
+ * @param {Set<string>|string[]} [options.baselineIds] 加载时磁盘上的 id 集合
488
+ */
489
+ export function mergeArchives(base, incoming, options = {}) {
490
+ const baseline =
491
+ options.baselineIds instanceof Set
492
+ ? options.baselineIds
493
+ : Array.isArray(options.baselineIds)
494
+ ? new Set(options.baselineIds)
495
+ : null;
496
+
497
+ // 墓碑取并集:两边删过的都算删过,谁也别复活
498
+ const tombstones = new Set([
499
+ ...(base.manifest?.deleted_ids ?? []),
500
+ ...(incoming.manifest?.deleted_ids ?? []),
501
+ ]);
502
+
503
+ const memoryIds = new Set(base.diary.map((e) => e.id));
504
+ const diskIds = new Set(incoming.diary.map((e) => e.id));
505
+
506
+ let merged;
507
+ if (baseline === null) {
508
+ merged = [...base.diary];
509
+ for (const e of incoming.diary) if (!memoryIds.has(e.id)) merged.push(e);
510
+ } else {
511
+ merged = [];
512
+ // 1) 内存里的:磁盘没有它,而基线有 → 别人删了,丢掉
513
+ for (const e of base.diary) {
514
+ if (diskIds.has(e.id)) { merged.push(e); continue; }
515
+ if (baseline.has(e.id)) continue; // 别人删的
516
+ merged.push(e); // 我们自己刚加的
517
+ }
518
+ // 2) 磁盘上的:内存没有它 → 先问墓碑,再问基线
519
+ for (const e of incoming.diary) {
520
+ if (memoryIds.has(e.id)) continue;
521
+ if (tombstones.has(e.id)) continue; // 删过,永不复活
522
+ if (baseline.has(e.id)) continue; // 我们自己删的
523
+ merged.push(e); // 别人刚加的
524
+ }
525
+ }
526
+ // ⚠️ **这里不要再兜底过滤一遍。**
527
+ //
528
+ // 写过一版 `merged = merged.filter(e => !tombstones.has(e.id))`,看着更保险,
529
+ // 实际把**导入**弄坏了:焚毁立碑 → 导入同一个包 → 磁盘上的墓碑还在
530
+ // (墓碑取并集)→ 刚导入的条目被滤掉 → **导入等于没导**。端到端测试当场抓到。
531
+ //
532
+ // 正解是各管各的:
533
+ // · 内存里活着的条目**以内存为准** —— 它要么是我们刚加的,要么已经
534
+ // 在循环 1 里按基线判过了
535
+ // · 墓碑只管"**从磁盘新加进来的**"那一路(循环 2),也就是基线
536
+ // 帮不上忙的那一种
537
+ merged.sort((a, b) => a.created_at - b.created_at);
538
+
539
+ const traitByKey = new Map(base.relationship.traits.map((t) => [t.key, t]));
540
+ for (const t of incoming.relationship.traits) {
541
+ const existing = traitByKey.get(t.key);
542
+ if (existing === undefined) traitByKey.set(t.key, t);
543
+ else existing.confidence = Math.max(existing.confidence, t.confidence);
544
+ }
545
+
546
+
547
+ const termSet = new Set((base.lexicon.items ?? []).map((i) => i.term));
548
+ const items = [...(base.lexicon.items ?? [])];
549
+ for (const item of incoming.lexicon.items ?? []) {
550
+ if (termSet.has(item.term)) continue;
551
+ termSet.add(item.term);
552
+ items.push(item);
553
+ }
554
+
555
+ return {
556
+ manifest: {
557
+ ...base.manifest,
558
+ created_at: Math.min(base.manifest.created_at, incoming.manifest.created_at),
559
+ updated_at: Date.now(),
560
+ diary_count: merged.length,
561
+ deleted_ids: [...tombstones],
562
+ },
563
+ persona: base.persona,
564
+ relationship: {
565
+ familiarity: Math.max(base.relationship.familiarity, incoming.relationship.familiarity),
566
+ nickname: base.relationship.nickname || incoming.relationship.nickname,
567
+ traits: [...traitByKey.values()],
568
+ },
569
+ lexicon: { items },
570
+ diary: merged,
571
+ };
572
+ }