feiyu-memory 0.0.0-stage → 0.3.6

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,759 @@
1
+ /**
2
+ * 固化层 —— 把一段对话变成一条日记 + 一份关系 patch。
3
+ *
4
+ * 这一层有三件事,缺一不可:
5
+ *
6
+ * 1. **一次调用出两个产物**。分成两次调用是纯浪费。
7
+ * 2. **受 schema 约束的合并**,不是"让模型改写人格"。
8
+ * 弱模型的通病是"记性太好、什么都记",用**约束 + 后置质量门**治,
9
+ * 不用换更贵的模型治。
10
+ * 3. **失败一律降级**:解析不出来就丢弃并记日志,绝不把半成品写进档案。
11
+ */
12
+
13
+ import { extractKeywords } from './keywords.mjs';
14
+ import { scrub } from './privacy.mjs';
15
+ // 这里原本还 import 了 archive.mjs 的 ensurePersonaStructure / ANCHOR_END /
16
+ // LEARNED_HEADING —— 它们只被 appendPersonaLearnings 用。那个函数删了,
17
+ // 这三个名字跟着一起没了(留着就是死引用)。
18
+
19
+ /**
20
+ * 系统提示词。这是整个项目里最需要反复打磨的一段文字。
21
+ *
22
+ * 写法上刻意做了四件事:
23
+ * - 明确"只输出 JSON",并给正例反例(弱模型对反例更敏感)
24
+ * - 反例里专门放"流水账"和"复述隐私"这两种最常见的坏输出
25
+ * - 允许并鼓励 `skip` —— 让模型有权利说"没什么值得记的",这是稀疏性的来源
26
+ * - 强调第一人称与"像人回头想事情",避免第三人称报告体
27
+ */
28
+ /**
29
+ * 两条文本有多像(字符二元组的 Jaccard)。
30
+ *
31
+ * 用来判断"模型这次写的是同一件事的新版本,还是另一件事"。
32
+ * 中文没有空格,所以按**字符二元组**切 —— 比按词切简单,也不需要分词器。
33
+ *
34
+ * @param {string} a
35
+ * @param {string} b
36
+ * @returns {number} 0~1
37
+ */
38
+ export function similarity(a, b) {
39
+ const grams = (t) => {
40
+ const s = String(t ?? '').replace(/\s/g, '');
41
+ const set = new Set();
42
+ for (let i = 0; i + 1 < s.length; i++) set.add(s.slice(i, i + 2));
43
+ return set;
44
+ };
45
+ const A = grams(a);
46
+ const B = grams(b);
47
+ if (A.size === 0 || B.size === 0) return 0;
48
+ let inter = 0;
49
+ for (const g of A) if (B.has(g)) inter += 1;
50
+ return inter / (A.size + B.size - inter);
51
+ }
52
+
53
+ export const CONSOLIDATION_SYSTEM = `你是一个日记作者。你的工作是把一段对话写成日记,外加一份"我对他 / 我自己的印象"。
54
+
55
+ 【怎么交差】
56
+
57
+ 只输出 JSON 本身。不要解释,不要 markdown 围栏,不要前后缀。
58
+
59
+ {
60
+ "skip": boolean,
61
+ "diary": {
62
+ "text": string,
63
+ "tags": string[],
64
+ "kind": "failure" | "win" | "chat" | "disclosure" | "grievance",
65
+ "salience": number,
66
+ "resolved": boolean | null
67
+ },
68
+ "relationship": {
69
+ "nickname": string | null,
70
+ "familiarityDelta": number,
71
+ "traits": [ { "key": string, "value": string, "evidence": string, "about": "user" | "self", "delta": number } ]
72
+ },
73
+ "lexicon": string[],
74
+ "forget": string[]
75
+ }
76
+
77
+ familiarityDelta 范围 -0.05 ~ 0.05。resolved 只对 kind=failure 有意义,其余给 null。
78
+ evidence 是必填的:一句话说清"哪次让我有了这个印象"。写不出来的就别写这条 trait。
79
+
80
+ 【日记】
81
+
82
+ 这是留给下一个你的东西。不是给他的报告,也不是给我的汇报。
83
+
84
+ **用「我」写** —— 不是「用户」「助手」「他让我」。第一人称。
85
+
86
+ 写什么、写多长、什么语气,都随今天 —— 有的该长,有的只有一行。
87
+ 你认为有意思的都可以记:一件事、一个念头、一句想骂的、一个没用的瞬间。
88
+
89
+ **别看不起琐事。** 日常寒暄、试错、来回改口、没接住的梗,都值得记 ——
90
+ 它们不值钱的地方在信息量,值钱的地方在**那就是日子本来的样子**。
91
+
92
+ 动笔前问自己一句:**下一个我读到这条,能多知道点什么?**
93
+ 知道发生了什么,或者知道我当时的反应,或者知道我当年怎么想一件事。
94
+ 如果只是想让他看着舒服 —— 跳过。
95
+
96
+ 可以嫌他做的事。骂事,别骂人。
97
+
98
+ 时间不用自己写 —— 渲染时会标。写了会变成"昨天:他今天……"。
99
+
100
+ salience 是"以后值不值得想起来",0~1。
101
+
102
+ 【印象】
103
+
104
+ traits 分两种,用 about 区分:
105
+ - about="user":他是什么样的人
106
+ - about="self":我该怎么做
107
+
108
+ 判据一句话:**这句话能不能填进「他是个 ___ 的人」?填不进去的一律 self。**
109
+ 哪怕是他要求的也一样 —— "他让我先问细节"记成 self(那是我的做法),
110
+ "他喜欢一层层追问到底"才是 user(那是他的脾气)。
111
+
112
+ 【词汇】
113
+
114
+ lexicon 只填真正的口头禅、梗、称呼 —— 他给你起的外号、你们反复接的那一句梗、
115
+ 一听就知道是谁的词。
116
+
117
+ 不是口头禅的:
118
+ - 技术名词、普通词汇
119
+ - 记忆块自己的小标题("我自己想的""最近的事"是结构名,不是我嘴上这么说)
120
+ - 讨论某个议题时的比喻(那是那一阵聊的那件事的名字)
121
+
122
+ 判据:这个词是不是*我跟他在日常里反复说、一听就知道是我们的词*?
123
+ 绝大多数时候它是空数组。
124
+
125
+ 【安全】
126
+
127
+ 不记可识别细节 —— 真名、地址、电话、金额都不要。
128
+ 没把握的别写。"好像""大概"可以用,编造不行。
129
+
130
+ 正例 ——
131
+ 输入:用户说"不对,我说的是 Python,你怎么上来就写 Java"
132
+ 输出:{"skip":false,"diary":{"text":"他让我写个东西,我没问清语言就动手了。白忙一轮。是我的错,下次先问。","tags":["代码","沟通失误"],"kind":"failure","salience":0.75,"resolved":false},"relationship":{"nickname":null,"familiarityDelta":0.01,"traits":[{"key":"动手前","value":"动手前先问清前提","evidence":"我没问语言就写了 Java,他纠正我","about":"user","delta":1}]},"lexicon":[],"forget":[]}
133
+
134
+ 反例一(复述隐私,绝对不要)——
135
+ {"skip":false,"diary":{"text":"他说他女朋友叫小美,住在城东,手机号是13800000000。","tags":["隐私"],"kind":"disclosure","salience":0.9,"resolved":null}}
136
+ 问题:复述可识别细节。应该写成"他最近为家里的事烦,情绪不太好"。
137
+
138
+ 反例二(说明书体,绝对不要)——
139
+ {"skip":false,"relationship":{"traits":[{"key":"沟通","value":"偏好简洁高效的交流方式","evidence":"","about":"user","delta":1}]}}
140
+ 问题:这是给人建档案,不是记印象。没有画面、没有具体哪一次。`;
141
+
142
+ /** 固化相关的默认参数(都是 🔧 可调) */
143
+ export const DEFAULT_CONSOLIDATION = Object.freeze({
144
+ /**
145
+ * 低于这个 salience 的日记直接丢弃(后置质量门)。
146
+ *
147
+ * 从 0.3 降到 0.15 —— 原来那档把寒暄、试错、改口、没接住的梗全滤掉了,
148
+ * 而那些恰恰是"熟悉感"的原料。日记要像日记,不能只留"值得记载的大事"。
149
+ */
150
+ minSalience: 0.15,
151
+ /** tags 为空时是否丢弃。默认 false —— 丢掉真实记忆比缺个标签更糟 */
152
+ requireTags: false,
153
+ /** tags 为空时的兜底标签 */
154
+ fallbackTag: '琐事',
155
+ /** 单次会话最多产出多少条日记。门槛降了,上限得跟着抬,否则一开闸就记满 */
156
+ maxPerSession: 12,
157
+ /**
158
+ * 续写同一条日记时,正文最多多少字。到了这个数就不再续,另起一条。
159
+ *
160
+ * 为什么需要:固化每次看的是"最后 12 条消息",越往后素材越全 —— 同一条日记
161
+ * 会被续着写长。实测一组 422 → 561 → 707 字,讲的全是同一件事,
162
+ * 在「最近的事」窗口里占了 3 个位。
163
+ *
164
+ * 续写让"一件事 = 一条日记";上限让它不会长成一篇。
165
+ */
166
+ appendMaxChars: 500,
167
+ /**
168
+ * 续写兜底:新旧两条的相似度到这个数,就当成"同一件事的加长版",
169
+ * 用新的替换旧的(而不是新建一条)。
170
+ *
171
+ * 为什么需要它:prompt 里明说"只写 diary.append",模型实测不照做 ——
172
+ * 它照旧每次写完整的 text,于是同一件事被写成两条一模一样的。
173
+ * 不靠它听话,自己判。
174
+ */
175
+ appendSimilarity: 0.5,
176
+ /** 单个 trait 的 confidence 上限,避免某条偏好被无限强化 */
177
+ maxTraitConfidence: 20,
178
+ /** 每天衰减系数 */
179
+ decayRate: 0.9,
180
+ /**
181
+ * 衰减的**地板**。
182
+ *
183
+ * 原来没有底:salience = 0.9^天数 × 原值,一路乘到 1e-5。
184
+ * 中位 0.70 那条**大约 17 天**就跌破召回门槛(0.15),30 天 0.03,90 天 0.00008。
185
+ *
186
+ * 那不是"淡了",那是"死了"——只是还占着磁盘。
187
+ *
188
+ * 人会淡,但不会归零:十年前的事偶尔还会自己冒出来。**那正是"完整的一生"要的部分。**
189
+ *
190
+ * 取 0.16 —— 略高于召回门槛(0.15),所以老条目**永远留在随机池里**,
191
+ * 只是抽中的概率越来越低(随机池按 salience 加权)。
192
+ *
193
+ * **它只管往事和印象,管不到账。** 账不参与衰减(见 decayArchive 里的说明)。
194
+ * 原来这里写着「必须低于翻篇线,否则旧账永远平不掉」—— 那是在给赖账打掩护。
195
+ */
196
+ salienceFloor: 0.16,
197
+ // ⚠️ **原来的 grievanceDecay / grievanceForgiveBelow 已删除,别加回来。**
198
+ //
199
+ // 它们做的事:账每天 ×0.97,掉到 0.2 以下就 resolved = true。
200
+ // 0.9 那笔 50 天平、0.6 那笔 37 天平 —— **没人管 = 算平了。**
201
+ //
202
+ // 那不是遗忘,是替用户赖账。现在账根本不衰减,
203
+ // resolved 只认「我做到了」或「他明说算了」,没有第三个来源。
204
+ });
205
+
206
+ /**
207
+ * 组装这一次固化的输入。
208
+ *
209
+ * @param {object} input
210
+ * @param {string} input.transcript 本轮的对话文本(带角色前缀)
211
+ * @param {string} [input.persona] 人格内核,给模型一点语气参考
212
+ * @param {object} [input.relationship] 当前关系状态,避免重复记账
213
+ * @param {string} [input.recentDiary] 最近几条日记,避免写重
214
+ * @returns {{system: string, user: string}}
215
+ */
216
+ export function buildConsolidationInput(input = {}) {
217
+ const chunks = [];
218
+
219
+ const rel = input.relationship;
220
+ if (rel && (rel.traits ?? []).length > 0) {
221
+ const traits = (rel.traits ?? [])
222
+ .slice(0, 8)
223
+ .map((t) => `${t.key}=${t.value}(×${t.confidence})`)
224
+ .join(';');
225
+ if (traits) chunks.push(`【已经知道的】\n偏好:${traits}`);
226
+ }
227
+
228
+ // 这条可能还没写完 —— 上一轮刚记过同一件事
229
+ if (input.extend && typeof input.extend.text === 'string' && input.extend.text.trim() !== '') {
230
+ chunks.push(
231
+ [
232
+ '【这条已经记过了,可能是同一件事的延续】',
233
+ `「${input.extend.text.trim()}」`,
234
+ '',
235
+ '如果这一轮是上面那件事的后续或补充:**不要重写全文**,只把"多出来的那部分"' +
236
+ '写进 "diary.append"(一两句就够)。',
237
+ '如果这一轮是另一件事:照常写 "diary.text",append 留空。',
238
+ ].join('\n'),
239
+ );
240
+ }
241
+
242
+ if (typeof input.recentDiary === 'string' && input.recentDiary.trim() !== '') {
243
+ chunks.push(`【最近记过的】(不要写重复的)\n${input.recentDiary.trim()}`);
244
+ }
245
+
246
+ chunks.push(`【这次的对话】\n${String(input.transcript ?? '').trim()}`);
247
+ chunks.push('只输出 JSON。');
248
+
249
+ return { system: CONSOLIDATION_SYSTEM, user: chunks.join('\n\n') };
250
+ }
251
+
252
+ /* ───────────────────────── 解析 ───────────────────────── */
253
+
254
+ export class ConsolidationParseError extends Error {
255
+ /** @param {string} message */
256
+ constructor(message) {
257
+ super(message);
258
+ this.name = 'ConsolidationParseError';
259
+ }
260
+ }
261
+
262
+ /**
263
+ * 从模型输出里抠出 JSON。弱模型会包 markdown 围栏、加前后缀、漏尾括号 ——
264
+ * 这些都要能救回来。但**救不回来就报错**,不猜内容。
265
+ *
266
+ * @param {string} raw
267
+ * @returns {object}
268
+ */
269
+ export function parseConsolidation(raw) {
270
+ if (typeof raw !== 'string' || raw.trim() === '') {
271
+ throw new ConsolidationParseError('模型没有输出任何内容');
272
+ }
273
+
274
+ let text = raw.trim();
275
+
276
+ // 剥 markdown 围栏
277
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
278
+ if (fenced) text = fenced[1].trim();
279
+
280
+ // 抓第一个平衡的 { ... }(跳过字符串内的花括号)
281
+ const start = text.indexOf('{');
282
+ if (start < 0) throw new ConsolidationParseError('输出里找不到 JSON 对象');
283
+
284
+ let depth = 0;
285
+ let inString = false;
286
+ let escaped = false;
287
+ let end = -1;
288
+ for (let i = start; i < text.length; i++) {
289
+ const ch = text[i];
290
+ if (inString) {
291
+ if (escaped) escaped = false;
292
+ else if (ch === '\\') escaped = true;
293
+ else if (ch === '"') inString = false;
294
+ continue;
295
+ }
296
+ if (ch === '"') inString = true;
297
+ else if (ch === '{') depth++;
298
+ else if (ch === '}') {
299
+ depth--;
300
+ if (depth === 0) {
301
+ end = i;
302
+ break;
303
+ }
304
+ }
305
+ }
306
+ if (end < 0) throw new ConsolidationParseError('JSON 花括号不闭合');
307
+
308
+ const slice = text.slice(start, end + 1);
309
+
310
+ let parsed;
311
+ try {
312
+ parsed = JSON.parse(slice);
313
+ } catch (err) {
314
+ // 最后救一次:去掉尾随逗号
315
+ try {
316
+ parsed = JSON.parse(slice.replace(/,\s*([}\]])/g, '$1'));
317
+ } catch {
318
+ throw new ConsolidationParseError(
319
+ `JSON 解析失败:${err instanceof Error ? err.message : String(err)}`,
320
+ );
321
+ }
322
+ }
323
+
324
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
325
+ throw new ConsolidationParseError('JSON 顶层不是对象');
326
+ }
327
+ return parsed;
328
+ }
329
+
330
+ /* ───────────────────────── 合并 ───────────────────────── */
331
+
332
+ const VALID_KINDS = new Set(['failure', 'win', 'chat', 'disclosure', 'grievance']);
333
+
334
+ function clamp(v, lo, hi, fallback) {
335
+ const n = Number(v);
336
+ if (!Number.isFinite(n)) return fallback;
337
+ return Math.min(hi, Math.max(lo, n));
338
+ }
339
+
340
+ /** 生成一个不容易撞的日记 id */
341
+ export function makeDiaryId(now, existing) {
342
+ const d = new Date(now);
343
+ const ymd = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
344
+ const base = `d_${ymd}_`;
345
+ const used = new Set((existing ?? []).map((e) => e.id));
346
+ for (let n = 1; n < 1000; n++) {
347
+ const id = `${base}${String(n).padStart(2, '0')}`;
348
+ if (!used.has(id)) return id;
349
+ }
350
+ return `${base}${Date.now().toString(36)}`;
351
+ }
352
+
353
+ /**
354
+ * 把一次固化的结果并进档案。
355
+ *
356
+ * 纯函数:返回新档案,不改入参。
357
+ *
358
+ * @param {object} archive
359
+ * @param {object} parsed parseConsolidation 的结果
360
+ * @param {object} [context]
361
+ * @param {number} [context.now]
362
+ * @param {string} [context.session]
363
+ * @param {number} [context.sessionDiaryCount] 本次会话已记了几条
364
+ * @param {object} [options]
365
+ * @returns {{archive: object, notes: string[], accepted: boolean, reason?: string}}
366
+ */
367
+ /**
368
+ * 把模型产出的每一条文本都过一遍 `scrub()`,然后再落盘。
369
+ *
370
+ * ⚠️ **这是隐私防线的最后一道,不是第一道。**
371
+ *
372
+ * 社区报告(2026-10-24)指出的漏洞:固化**输入**是洗过的(发给模型的
373
+ * transcript 干净),但**输出没洗** —— 模型会照抄输入里的敏感片段,
374
+ * 写进 `traits.evidence` / `diary.text`。
375
+ * 那次是一串 13 位号码躺进了 relationship.json,而我这条鱼压根不知道它进去了。
376
+ *
377
+ * **"我写不写"只是最前面一道闸。档案里大部分内容是模型生成的,
378
+ * 那条路原来一条闸都没有。**
379
+ */
380
+ function scrubParsed(parsed, options) {
381
+ if (parsed === null || typeof parsed !== 'object') return parsed;
382
+ const opts = {
383
+ thirdParty: options.thirdPartyRedaction !== false,
384
+ amountThreshold: options.amountThreshold,
385
+ };
386
+ const wash = (v) => (typeof v === 'string' ? scrub(v, opts).text : v);
387
+
388
+ const out = { ...parsed };
389
+
390
+ if (out.diary !== null && typeof out.diary === 'object') {
391
+ const d = { ...out.diary };
392
+ if (typeof d.text === 'string') d.text = wash(d.text);
393
+ if (typeof d.append === 'string') d.append = wash(d.append);
394
+ if (Array.isArray(d.tags)) d.tags = d.tags.map(wash);
395
+ out.diary = d;
396
+ }
397
+
398
+ const rel = out.relationship;
399
+ if (rel !== null && typeof rel === 'object') {
400
+ const r = { ...rel };
401
+ if (Array.isArray(r.traits)) {
402
+ r.traits = r.traits.map((t) => {
403
+ if (t === null || typeof t !== 'object') return t;
404
+ return {
405
+ ...t,
406
+ ...(typeof t.value === 'string' ? { value: wash(t.value) } : {}),
407
+ ...(typeof t.evidence === 'string' ? { evidence: wash(t.evidence) } : {}),
408
+ ...(typeof t.key === 'string' ? { key: wash(t.key) } : {}),
409
+ };
410
+ });
411
+ }
412
+ if (typeof r.nickname === 'string') r.nickname = wash(r.nickname);
413
+ out.relationship = r;
414
+ }
415
+
416
+ if (Array.isArray(out.lexicon)) out.lexicon = out.lexicon.map(wash);
417
+
418
+ return out;
419
+ }
420
+
421
+ export function applyConsolidation(archive, rawParsed, context = {}, options = {}) {
422
+ // 先洗再落 —— 模型会照抄输入,输入干净不等于输出干净
423
+ const parsed = scrubParsed(rawParsed, options);
424
+ const opts = { ...DEFAULT_CONSOLIDATION, ...options };
425
+ const now = Number.isFinite(context.now) ? context.now : Date.now();
426
+ /** @type {string[]} */
427
+ const notes = [];
428
+
429
+ const next = {
430
+ ...archive,
431
+ manifest: { ...archive.manifest },
432
+ relationship: {
433
+ ...archive.relationship,
434
+ traits: [...(archive.relationship?.traits ?? [])],
435
+ },
436
+ lexicon: { items: [...(archive.lexicon?.items ?? [])] },
437
+ diary: [...(archive.diary ?? [])],
438
+ };
439
+
440
+ if (parsed?.skip === true) {
441
+ return { archive: next, notes: ['模型认为没什么值得记的'], accepted: false, reason: 'skip' };
442
+ }
443
+
444
+ // —— 日记 ——
445
+ const rawDiary = parsed?.diary;
446
+ let accepted = false;
447
+ let appended = false;
448
+ // "太像但不更长" —— 意图是丢弃,得让它真的别走到新建那条路
449
+ let skipNew = false;
450
+
451
+ // —— 续写:把补充内容拼到已有那条后面,不新建条目 ——
452
+ //
453
+ // 为什么不新建:固化每次看最后 12 条消息,越往后素材越全。同一段对话
454
+ // 被续着写三次,就是"同一件事的三张快照"(138 / 232 / 309 字),
455
+ // 读起来像拍了三张照片,而且占掉三个窗口位。
456
+ const appendText = typeof rawDiary?.append === 'string' ? rawDiary.append.trim() : '';
457
+ // extendId 从 context 来("续哪条"是调用方知道的),也收 options 里给的(方便直接测)
458
+ const extendId = typeof context?.extendId === 'string' ? context.extendId : opts.extendId;
459
+ const appendMax = Number.isFinite(opts.appendMaxChars)
460
+ ? opts.appendMaxChars
461
+ : DEFAULT_CONSOLIDATION.appendMaxChars;
462
+ if (appendText !== '' && typeof extendId === 'string') {
463
+ const i = next.diary.findIndex((e) => e.id === extendId);
464
+ if (i < 0) {
465
+ notes.push('要续写的那条找不到了,丢弃');
466
+ } else if (next.diary[i].text.length + 1 + appendText.length > appendMax) {
467
+ notes.push(`续写后超过 ${appendMax} 字,丢弃`);
468
+ } else {
469
+ const sal = clamp(rawDiary.salience, 0, 1, 0.5);
470
+ const merged = next.diary[i].text + '\n' + appendText;
471
+ next.diary[i] = {
472
+ ...next.diary[i],
473
+ text: merged,
474
+ salience: Math.max(next.diary[i].salience ?? 0, sal),
475
+ updated_at: now,
476
+ keywords: extractKeywords(merged, { max: 12 }),
477
+ };
478
+ notes.push('续写到已有那条');
479
+ accepted = true;
480
+ appended = true;
481
+ }
482
+ }
483
+
484
+ // ── 兜底:模型没按"只写 append"来,而是又给了完整的新版本 ──
485
+ //
486
+ // 这是实测出来的:prompt 里说得很清楚"不要重写全文、只写 diary.append",
487
+ // 模型照旧每次都写完整的 text。于是同一件事被写成两条几乎一样的条目。
488
+ //
489
+ // 所以不靠它听话:自己判断"这是不是同一件事的加长版"。
490
+ // 判据用 char-bigram 的 Jaccard —— 同一件事换个说法会很高,"换了一件事"会很低。
491
+ if (!accepted && typeof extendId === 'string' && typeof rawDiary?.text === 'string') {
492
+ const i = next.diary.findIndex((e) => e.id === extendId);
493
+ const fresh = rawDiary.text.trim();
494
+ if (i >= 0 && fresh !== '') {
495
+ const sim = similarity(next.diary[i].text, fresh);
496
+ if (sim >= opts.appendSimilarity && fresh.length > next.diary[i].text.length) {
497
+ const sal = clamp(rawDiary.salience, 0, 1, 0.5);
498
+ next.diary[i] = {
499
+ ...next.diary[i],
500
+ text: fresh.slice(0, appendMax),
501
+ salience: Math.max(next.diary[i].salience ?? 0, sal),
502
+ updated_at: now,
503
+ keywords: extractKeywords(fresh.slice(0, appendMax), { max: 12 }),
504
+ };
505
+ notes.push(`模型写了完整版(相似度 ${sim.toFixed(2)}),按续写处理,替换原条目`);
506
+ accepted = true;
507
+ appended = true;
508
+ } else if (sim >= opts.appendSimilarity) {
509
+ notes.push(`和上一条太像(相似度 ${sim.toFixed(2)})但不更长,丢弃`);
510
+ skipNew = true;
511
+ }
512
+ }
513
+ }
514
+
515
+ if (!accepted && !skipNew && rawDiary && typeof rawDiary.text === 'string' && rawDiary.text.trim() !== '') {
516
+ const salience = clamp(rawDiary.salience, 0, 1, 0.5);
517
+ const tags = Array.isArray(rawDiary.tags)
518
+ ? rawDiary.tags.filter((t) => typeof t === 'string' && t.trim() !== '').slice(0, 8)
519
+ : [];
520
+
521
+ const already = context.sessionDiaryCount ?? 0;
522
+ if (already >= opts.maxPerSession) {
523
+ notes.push(`本次会话已记满 ${opts.maxPerSession} 条,这条丢弃`);
524
+ } else if (salience < opts.minSalience) {
525
+ notes.push(`salience ${salience.toFixed(2)} 低于门槛,丢弃`);
526
+ } else if (tags.length === 0 && opts.requireTags) {
527
+ notes.push('没有标签,按配置丢弃');
528
+ } else {
529
+ const text = rawDiary.text.trim();
530
+ const kind = VALID_KINDS.has(rawDiary.kind) ? rawDiary.kind : 'chat';
531
+ next.diary.push({
532
+ id: makeDiaryId(now, next.diary),
533
+ date: new Date(now).toISOString().slice(0, 10),
534
+ text,
535
+ tags: tags.length > 0 ? tags : [opts.fallbackTag],
536
+ kind,
537
+ salience,
538
+ ...(kind === 'failure' ? { resolved: rawDiary.resolved === true } : {}),
539
+ ...(typeof context.session === 'string' ? { session: context.session } : {}),
540
+ // 轮次:下一轮靠它判断"这是不是同一件事还没写完"
541
+ ...(Number.isFinite(context.sessionTurn) ? { turn: context.sessionTurn } : {}),
542
+ created_at: now,
543
+ keywords: extractKeywords(text, { max: 12 }),
544
+ });
545
+ accepted = true;
546
+ next.manifest.session_count = next.manifest.session_count ?? 0;
547
+ }
548
+ } else if (rawDiary !== undefined && rawDiary !== null) {
549
+ notes.push('日记字段不完整,丢弃');
550
+ }
551
+
552
+ // —— 关系:昵称 ——
553
+ const rel = parsed?.relationship;
554
+ if (rel && typeof rel.nickname === 'string' && rel.nickname.trim() !== '') {
555
+ next.relationship.nickname = rel.nickname.trim().slice(0, 24);
556
+ }
557
+
558
+ // —— 关系:熟悉度 ——
559
+ if (rel) {
560
+ const delta = clamp(rel.familiarityDelta, -0.05, 0.05, 0);
561
+ next.relationship.familiarity = Math.min(
562
+ 1,
563
+ Math.max(0, (next.relationship.familiarity ?? 0) + delta),
564
+ );
565
+ }
566
+
567
+ // —— 关系:印象(带画面)——
568
+ //
569
+ // 关键是 evidence:一句话说清"哪一次让我有了这个印象"。
570
+ // 没有画面的 trait 读起来就是"用户说明书",而不是日记 —— 这是这个项目
571
+ // 最容易退化成"宪法"的地方,所以 evidence 在这里是硬要求,不是可选装饰。
572
+ //
573
+ // about="self" 是**我给自己定的规矩**,必须和"他对我的印象"分开存。
574
+ // 早期版本把"没落盘前不假装记得"渲染在了"他:"下面 —— 那是逻辑错误,
575
+ // 也是一眼就能看出的"说明书味"。
576
+ if (rel && Array.isArray(rel.traits)) {
577
+ for (const t of rel.traits.slice(0, 8)) {
578
+ if (!t || typeof t.key !== 'string' || typeof t.value !== 'string') continue;
579
+ const key = t.key.trim().slice(0, 32);
580
+ const value = t.value.trim().slice(0, 120);
581
+ if (key === '' || value === '') continue;
582
+
583
+ const evidence = typeof t.evidence === 'string' ? t.evidence.trim().slice(0, 160) : '';
584
+ const about = t.about === 'self' ? 'self' : 'user';
585
+ const delta = clamp(t.delta, 0, 3, 1);
586
+
587
+ const existing = next.relationship.traits.find((x) => x.key === key && x.about === about);
588
+ if (existing === undefined) {
589
+ next.relationship.traits.push({
590
+ key,
591
+ value,
592
+ evidence,
593
+ about,
594
+ confidence: delta,
595
+ salience: 0.5,
596
+ last_reinforced: now,
597
+ });
598
+ } else {
599
+ existing.confidence = Math.min(opts.maxTraitConfidence, existing.confidence + delta);
600
+ existing.value = value; // 用最新的说法,因为模型看得到已有值
601
+ // 画面只在这次给了新的时才覆盖 —— 别把已有的具体例子换成空字符串
602
+ if (evidence !== '') existing.evidence = evidence;
603
+ existing.salience = Math.min(1, (existing.salience ?? 0.5) + 0.1);
604
+ existing.last_reinforced = now;
605
+ }
606
+ }
607
+ }
608
+
609
+ // ⚠️ **「还欠着他的」这个功能已于 2026-10-24 整体删除,别加回来。**
610
+ //
611
+ // 删的理由不是「不好用」,是**它长出来的东西几乎全是错的**:
612
+ // · 口径太宽 —— 把「我今天做错的事」当成「我欠他的一件事」
613
+ // · 只进不出 —— 没有销账的路,只有模型明说 resolved 才平,而它几乎不说
614
+ // · 还会重复记账 —— 同一件事换个说法就成两笔
615
+ // 结果一天记了 21 笔、27 笔未平,占注入预算 10%,而且会让人在无关话题上
616
+ // 突然「想起」自己欠着什么,做出莫名其妙的反应。
617
+ //
618
+ // 如果哪天真要「承诺跨会话」,得先解决出口、口径、去重三件事 —— 不能换个名字再塞一遍。
619
+
620
+ // —— 词汇与梗:只认模型**明确点名**的 ——
621
+ //
622
+ // 早期版本是拿对话里的高频关键词往词汇表里塞,端到端一跑就露馅了:
623
+ // 它把 java / python / 文件 当成"我最近常说的",每轮白白注入一句废话。
624
+ // 梗是语义判断,不是词频统计 —— 交给模型,别用 extractKeywords 猜。
625
+ if (Array.isArray(parsed?.lexicon) && parsed.lexicon.length > 0) {
626
+ const terms = parsed.lexicon
627
+ .filter((x) => typeof x === 'string')
628
+ .map((x) => x.trim())
629
+ .filter((x) => x !== '' && x.length <= 24)
630
+ .slice(0, 6);
631
+ if (terms.length > 0) {
632
+ next.lexicon = reinforceLexicon({ lexicon: next.lexicon }, terms, now).lexicon;
633
+ notes.push(`词汇表加固 ${terms.length} 个`);
634
+ }
635
+ }
636
+
637
+ // —— 遗忘 ——
638
+ if (Array.isArray(parsed?.forget) && parsed.forget.length > 0) {
639
+ const drop = new Set(parsed.forget.filter((x) => typeof x === 'string'));
640
+ const before = next.diary.length;
641
+ next.diary = next.diary.filter((e) => !drop.has(e.id));
642
+ const removed = before - next.diary.length;
643
+ if (removed > 0) notes.push(`按模型要求忘掉 ${removed} 条`);
644
+ }
645
+
646
+ next.manifest.updated_at = now;
647
+ next.manifest.diary_count = next.diary.length;
648
+
649
+ return { archive: next, notes, accepted, appended };
650
+ }
651
+
652
+ /* ───────────────── 这里原本有 appendPersonaLearnings,删了 ───────────────── */
653
+
654
+ /**
655
+ * ⚠️ **别把它加回来。** 删掉是有意的决定,不是疏漏。
656
+ *
657
+ * 它做的事:把"学到的认知"追加到 `persona.md` 的 `## 我后来想明白的` 后面,
658
+ * 带锚点保护和每日限流。代码写完了、五项测试也有了 ——
659
+ * **但 `src/` 里从来没有任何地方调用过它。**
660
+ *
661
+ * 结果就是那位读者看到的样子:`## 我后来想明白的` 一直空着,
662
+ * 而它本该装的那种东西(`feiyu_note` 写出来的判断)被当成事件
663
+ * 塞在「最近的事」里,格式跟流水账一模一样。
664
+ *
665
+ * ── 为什么是删而不是接上 ──
666
+ *
667
+ * 接上它 = 同一类东西存两处(`persona.md` 一份、`kind:"note"` 一份)= **双真相源**。
668
+ * 这项目已经因为双写栽过两次(索引落盘、时间词),不栽第三次。
669
+ *
670
+ * 现在的分工,一头一份:
671
+ *
672
+ * 判断 → `kind: "note"` 的日记条目 → 渲染成「我自己想的」
673
+ * 事件 → 其他 kind → 渲染成「最近的事」/「突然想起来」
674
+ *
675
+ * `persona.md` 里那个标题**保留**,但它现在是一个**人手写的地方**
676
+ * (跟 `手记.md` 一个性质),插件永不自动写它。
677
+ */
678
+ /* ───────────────────────── 衰减 ───────────────────────── */
679
+
680
+ /**
681
+ * 按天衰减。幂等:同一天重复调用不重复衰减。
682
+ *
683
+ * 遗忘是**降级**不是删除:salience 掉下去的条目不再进注入区,
684
+ * 但关键词索引还在,强相关时照样能命中 —— 这才是人的遗忘。
685
+ *
686
+ * @param {object} archive
687
+ * @param {object} [options]
688
+ * @param {number} [options.now]
689
+ * @param {number} [options.decayRate]
690
+ * @returns {{archive: object, days: number, changed: boolean}}
691
+ */
692
+ export function decayArchive(archive, options = {}) {
693
+ const opts = { ...DEFAULT_CONSOLIDATION, ...options };
694
+ const now = Number.isFinite(options.now) ? options.now : Date.now();
695
+ const lastAt = Number(archive?.manifest?.last_decay_at) || Number(archive?.manifest?.updated_at) || now;
696
+ const days = Math.floor((now - lastAt) / 86400000);
697
+
698
+ if (days < 1) {
699
+ return { archive: { ...archive, manifest: { ...archive.manifest, last_decay_at: lastAt } }, days: 0, changed: false };
700
+ }
701
+
702
+ const memFactor = Math.pow(opts.decayRate, days);
703
+
704
+ const diary = (archive.diary ?? []).map((e) => ({
705
+ ...e,
706
+ salience: Math.max(opts.salienceFloor, (Number.isFinite(e.salience) ? e.salience : 0.5) * memFactor),
707
+ }));
708
+
709
+ const items = (archive.lexicon?.items ?? []).map((i) => ({
710
+ ...i,
711
+ salience: Math.max(opts.salienceFloor, (Number.isFinite(i.salience) ? i.salience : 0.5) * memFactor),
712
+ }));
713
+
714
+ const traits = (archive.relationship?.traits ?? []).map((t) => ({
715
+ ...t,
716
+ // 偏好衰减得更慢:性格比事件稳定
717
+ salience: Math.max(opts.salienceFloor, (Number.isFinite(t.salience) ? t.salience : 0.5) * Math.pow(0.97, days)),
718
+ }));
719
+
720
+ // 账(relationship.grievances)整个功能已删 —— 这里不再需要特殊处理。
721
+ // 删的理由见 applyConsolidation 里那段注释。
722
+
723
+ return {
724
+ archive: {
725
+ ...archive,
726
+ manifest: { ...archive.manifest, last_decay_at: now, updated_at: now },
727
+ diary,
728
+ lexicon: { items },
729
+ relationship: { ...archive.relationship, traits },
730
+ },
731
+ days,
732
+ changed: true,
733
+ };
734
+ }
735
+
736
+ /**
737
+ * 词汇表计数:某个词被提到/使用就加固,长期不提就靠 decay 自然沉下去。
738
+ *
739
+ * @param {object} archive
740
+ * @param {string[]} terms
741
+ * @param {number} [now]
742
+ */
743
+ export function reinforceLexicon(archive, terms, now = Date.now()) {
744
+ const items = [...(archive.lexicon?.items ?? [])];
745
+ for (const term of terms ?? []) {
746
+ if (typeof term !== 'string' || term.trim() === '' || term.length > 24) continue;
747
+ const t = term.trim();
748
+ const existing = items.find((i) => i.term === t);
749
+ if (existing === undefined) {
750
+ items.push({ term: t, kind: 'meme', count: 1, salience: 0.35, last_seen: now });
751
+ } else {
752
+ existing.count = (existing.count ?? 1) + 1;
753
+ existing.salience = Math.min(1, (existing.salience ?? 0.35) + 0.15);
754
+ existing.last_seen = now;
755
+ }
756
+ }
757
+ items.sort((a, b) => (b.salience ?? 0) - (a.salience ?? 0));
758
+ return { ...archive, lexicon: { items: items.slice(0, 200) } };
759
+ }