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