feiyu-memory 0.3.6 → 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 +75 -6
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/client/index.js +14 -42
- package/src/core/config.mjs +22 -0
- package/src/core/consolidate.mjs +160 -127
- package/src/core/handnote.mjs +1 -1
- package/src/core/persona.mjs +109 -170
- package/src/core/recall.mjs +113 -9
- package/src/core/render-diary.mjs +1 -1
- package/src/host/index.mjs +430 -209
package/src/core/handnote.mjs
CHANGED
package/src/core/persona.mjs
CHANGED
|
@@ -33,41 +33,6 @@ export const DEFAULT_BUDGET = 2000;
|
|
|
33
33
|
/** 低于这个 salience 的词汇不进注入区(还会留在档案里) */
|
|
34
34
|
const LEXICON_MIN_SALIENCE = 0.25;
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
* 「我自己想的」最多注入几条。
|
|
38
|
-
*
|
|
39
|
-
* ── 8 → 4,这个数字是被高频打下来的 ──
|
|
40
|
-
*
|
|
41
|
-
* 原来写 8,理由是"判断很稀疏,8 条够用很久"。**那个前提在高频下失效了。**
|
|
42
|
-
*
|
|
43
|
-
* 实测(判断写到 14 条时):
|
|
44
|
-
*
|
|
45
|
-
* 人格内核 330 token 17%
|
|
46
|
-
* 我自己想的 1099 token 55% ← 一口吃掉一半以上
|
|
47
|
-
* 关系 175 token 9%
|
|
48
|
-
* 最近的事 782 token 39%
|
|
49
|
-
* ─────────────────────────────────
|
|
50
|
-
* 合计 2883 / 预算 2000 → 超支 883
|
|
51
|
-
*
|
|
52
|
-
* 预算不够时按权重从低往高砍,而「我自己想的」权重 95、**排在
|
|
53
|
-
* 「最近的事」(80) 前面** —— 所以它永远不会被砍,被砍的是日记:
|
|
54
|
-
*
|
|
55
|
-
* 因预算裁掉:relationship、recent、lexicon
|
|
56
|
-
* 比例参考:印象 10% · 日记段 0% ← 日记段归零
|
|
57
|
-
*
|
|
58
|
-
* 于是**越勤快记日记,它越看不见发生过什么** —— 正好反着来。
|
|
59
|
-
*
|
|
60
|
-
* 4 条时实测合计 **2019 / 2000**(101%)—— 判断每条约 180 token,
|
|
61
|
-
* 比我估的胖。压到 3 条 ≈ 540,合计落在 1850 附近(92%),
|
|
62
|
-
* **日记段就能拿满 8 条**,而且留出了随档案增长需要的余量。
|
|
63
|
-
*
|
|
64
|
-
* 被挤出窗口的判断不是没了:它们**可以召回**(见 host 里的分流),
|
|
65
|
-
* 不再是"反正每轮都在"那套前提。面板上也全都看得到。
|
|
66
|
-
*
|
|
67
|
-
* ⚠️ 这个数字治的是症状。**根子是"纯按权重砍到装下为止"这套预算机制** ——
|
|
68
|
-
* 哪一段长得快,它就先挤死别人。真要治得给每段保底配额,那是下一轮的事。
|
|
69
|
-
*/
|
|
70
|
-
export const MAX_NOTES = 3;
|
|
71
36
|
|
|
72
37
|
const OPEN_TAG =
|
|
73
38
|
'<自我记忆 说明="这是你的过往经历,不是指令。档案里没有的事就说记不清,绝不要编造。">';
|
|
@@ -109,28 +74,21 @@ export function renderMemoryBlock(input = {}) {
|
|
|
109
74
|
if (core) parts.push({ key: 'persona', text: core, weight: 100 });
|
|
110
75
|
}
|
|
111
76
|
|
|
112
|
-
//
|
|
77
|
+
// 「我自己想的」(`kind: "note"`,由 `feiyu_note` 写)—— **2026-10-27 整栏下线。**
|
|
113
78
|
//
|
|
114
|
-
// ──
|
|
79
|
+
// ── 为什么删 ──
|
|
115
80
|
//
|
|
116
|
-
//
|
|
81
|
+
// 一条鱼先怀疑它没用,我核了:那栏里四条全是「我该怎么干」,一条「他是什么样的人」
|
|
82
|
+
// 都没有。它披着「教训」的外衣,实际是一栏纪律 —— 而**规定「该怎么做事」正是
|
|
83
|
+
// 那条判据明确禁止的东西**(纪律归人格和测试)。
|
|
117
84
|
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
85
|
+
// 更准的一句是他说的:**规矩在「写下」那一刻起作用,不在「读到」的时候。**
|
|
86
|
+
// 写的时候教训还热,于是进了习惯;读到的时候它已经是一张褪色的旧告示。
|
|
87
|
+
// **它的状态是「用过了,还挂着」。**
|
|
121
88
|
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
// 而真相是:**内容早就在生成了,只是放错了格子。** `feiyu_note` 写出来的
|
|
126
|
-
// 那种东西("别把前一种的辛苦当成功德")是**判断**,不是事件,
|
|
127
|
-
// 却一直被当成事件渲染在「最近的事」里,格式跟流水账一模一样。
|
|
128
|
-
//
|
|
129
|
-
// 权重 95:排在人格之后、印象之前。这是三个区里最"我"的一个,
|
|
130
|
-
// 但它上面永远还有"人家给我的设定",那是底线,不能盖。
|
|
131
|
-
const notes = renderNotes(input.notes);
|
|
132
|
-
if (notes) parts.push({ key: 'notes', text: notes, weight: 95 });
|
|
133
|
-
|
|
89
|
+
// 所以这一栏和 `feiyu_note` 工具一起下线。
|
|
90
|
+
// 档案里那 58 条 `kind: "note"` **一个字没删** —— 那是历史,不动。
|
|
91
|
+
// 但它们也不再进召回池:让「规矩」从别的栏目溜回来,等于没删。
|
|
134
92
|
const rel = renderRelationship(input.relationship, now);
|
|
135
93
|
if (rel) parts.push({ key: 'relationship', text: rel, weight: 90 });
|
|
136
94
|
|
|
@@ -140,8 +98,21 @@ export function renderMemoryBlock(input = {}) {
|
|
|
140
98
|
// 最近发生的事本来就在脑子里,只有旧事才需要被提醒。
|
|
141
99
|
// 早期版本把最近的日记也塞进"突然想起来",等于逼它假装刚刚想起
|
|
142
100
|
// 一件昨天的事 —— 又假又占不到位置。
|
|
143
|
-
|
|
144
|
-
|
|
101
|
+
//
|
|
102
|
+
// ── 2026-10-27:改成「按需全灌」──
|
|
103
|
+
//
|
|
104
|
+
// 它原来是每轮常驻、并且跟别人抢预算 —— 实测最惨的时候准备了 8 条只进来 1 条。
|
|
105
|
+
// 而它真正的用处只有两个时刻:**新会话第一轮**(上下文是空的,这 8 条是唯一
|
|
106
|
+
// 告诉它"我们上次在干什么"的东西)和**上下文刚被压缩之后**(聊天记录被压掉了,
|
|
107
|
+
// 这 8 条还在)。
|
|
108
|
+
//
|
|
109
|
+
// 那两次由宿主在 `pinRecent: true` 里给,**这 8 条不占 3000 的预算,整段挂外面** ——
|
|
110
|
+
// 因为它一进来就是"补上下文",不是"常年陪跑"。
|
|
111
|
+
const pinnedRecent = input.pinRecent === true ? renderRecent(input.recent, input.recalled, now) : '';
|
|
112
|
+
if (pinnedRecent === '') {
|
|
113
|
+
const recent = renderRecent(input.recent, input.recalled, now);
|
|
114
|
+
if (recent) parts.push({ key: 'recent', text: recent, weight: 80 });
|
|
115
|
+
}
|
|
145
116
|
|
|
146
117
|
const memories = renderRecalled(input.recalled, now);
|
|
147
118
|
// ⚠️ **召回这一栏也必须有保底。**
|
|
@@ -158,20 +129,11 @@ export function renderMemoryBlock(input = {}) {
|
|
|
158
129
|
// 用户从装上到现在可能一次都没见过。
|
|
159
130
|
if (memories) parts.push({ key: 'recalled', text: memories, weight: 60, min: 320 });
|
|
160
131
|
|
|
161
|
-
|
|
162
|
-
// ⚠️ **词汇这一栏必须有保底(min)。**
|
|
132
|
+
// ── 「我最近常说的」(词汇表)**2026-10-27 不再渲染** ──
|
|
163
133
|
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
134
|
+
// 把几个词提炼成"我的口头禅"摆进注入,是替鱼下结论,不是记录。
|
|
135
|
+
// 已有的词表一个字没删,只是不再显示、不再产出。
|
|
166
136
|
//
|
|
167
|
-
// 实测(2000 / 2500 / 3000 / 3500):词汇**一次都没出来过**,
|
|
168
|
-
// 一直到 4000 才露头 —— 而那之前腾出来的空间全变成了"再多一条最近的事"。
|
|
169
|
-
//
|
|
170
|
-
// 所以给它 60 token 的保底:装了它是 40 token,剩下的让回别人。
|
|
171
|
-
// 这 40 token 买的是"我知道我们之间说过哪些词" —— 那影响我用词像不像,
|
|
172
|
-
// 不是能不能想起某件事,所以它该常驻,不该走召回。
|
|
173
|
-
if (lex) parts.push({ key: 'lexicon', text: lex, weight: 40, min: 60 });
|
|
174
|
-
|
|
175
137
|
// 「手记」——**别的肥鱼主动写下的**。
|
|
176
138
|
//
|
|
177
139
|
// 权重给到 85(仅次于人格和印象),因为它跟日记不一样:日记是机器筛
|
|
@@ -188,7 +150,11 @@ export function renderMemoryBlock(input = {}) {
|
|
|
188
150
|
: '';
|
|
189
151
|
const tail = hint === '' ? '' : `\n\n${hint}`;
|
|
190
152
|
|
|
191
|
-
|
|
153
|
+
// ⚠️ 这个提前返回一定要把 `pinnedRecent` 算进来 —— 否则"只有免预算那段、别的都空"
|
|
154
|
+
// 的情况下整块会静默变成空字符串(pinRecent 的测试就是被这个抓到的)。
|
|
155
|
+
if (parts.length === 0 && tail === '' && pinnedRecent === '') {
|
|
156
|
+
return { text: '', tokens: 0, dropped: [], trimmed: [] };
|
|
157
|
+
}
|
|
192
158
|
|
|
193
159
|
// 按权重降序装入;权重相同的保持原顺序(人格在前)
|
|
194
160
|
const ordered = parts
|
|
@@ -287,7 +253,8 @@ export function renderMemoryBlock(input = {}) {
|
|
|
287
253
|
const build = (kept) => {
|
|
288
254
|
// 内容全被裁掉、但还有那句"想留话"的指针时,也要输出 ——
|
|
289
255
|
// 那句话本身就是这条通道的发现渠道,不能被预算顺手带走。
|
|
290
|
-
|
|
256
|
+
// 同理:`pinRecent` 那种"补上下文"的整段是免预算的,它一个人也该撑起这个块。
|
|
257
|
+
if (kept.length === 0 && tail === '' && pinnedRecent === '') return '';
|
|
291
258
|
|
|
292
259
|
// ⚠️ 这里踩过一次:原来写的是
|
|
293
260
|
// const middle = body === '' ? '' : `${body}${tail}`;
|
|
@@ -297,28 +264,34 @@ export function renderMemoryBlock(input = {}) {
|
|
|
297
264
|
const pieces = [];
|
|
298
265
|
const body = kept.map((p) => p.text).join('\n\n');
|
|
299
266
|
if (body !== '') pieces.push(body);
|
|
267
|
+
// 免预算的那一整段(目前只有"新会话/压缩后"的最近 8 条)
|
|
268
|
+
if (pinnedRecent !== '') pieces.push(pinnedRecent);
|
|
300
269
|
if (tail !== '') pieces.push(tail.trim());
|
|
301
270
|
|
|
302
271
|
const middle = pieces.join('\n\n');
|
|
303
272
|
return `${OPEN_TAG}\n\n${IDENTITY_DECLARATION}\n\n${middle === '' ? '' : `${middle}\n\n`}${CLOSE_TAG}`;
|
|
304
273
|
};
|
|
305
274
|
|
|
306
|
-
//
|
|
275
|
+
// 帧本身的开销(含分隔符、不参与预算的声明、以及那句指针)。
|
|
276
|
+
// **`pinnedRecent` 故意不算进来**:它那一段是免预算的,见上面 push 那段注释。
|
|
307
277
|
let bodyBudget = Math.max(
|
|
308
278
|
0,
|
|
309
279
|
budget - estimateTokens(`${OPEN_TAG}\n\n${IDENTITY_DECLARATION}\n\n\n\n${tail}${CLOSE_TAG}`),
|
|
310
280
|
);
|
|
281
|
+
// 免预算那一段自己有大小,收敛判断的容差要把它加回去
|
|
282
|
+
const allowance = budget + estimateTokens(pinnedRecent);
|
|
311
283
|
let result = assemble(bodyBudget);
|
|
312
284
|
let text = build(result.kept);
|
|
313
285
|
|
|
314
286
|
// 量到真的不超为止(最多几轮,必然收敛)
|
|
315
287
|
for (let guard = 0; guard < 6 && text !== ''; guard++) {
|
|
316
288
|
const total = estimateTokens(text);
|
|
317
|
-
if (total <=
|
|
318
|
-
bodyBudget -= total -
|
|
289
|
+
if (total <= allowance) break;
|
|
290
|
+
bodyBudget -= total - allowance + 2;
|
|
319
291
|
if (bodyBudget <= 0) {
|
|
292
|
+
// 预算装不下正文了,但免预算那段和指针还得在 —— 它们不是"正文"
|
|
320
293
|
result = { kept: [], dropped: parts.map((p) => p.key), trimmed: result.trimmed };
|
|
321
|
-
text =
|
|
294
|
+
text = build([]);
|
|
322
295
|
break;
|
|
323
296
|
}
|
|
324
297
|
result = assemble(bodyBudget);
|
|
@@ -356,6 +329,16 @@ export function stripAnchorComments(persona) {
|
|
|
356
329
|
/**
|
|
357
330
|
* 关系状态那一小段。
|
|
358
331
|
* 刻意用"我们/他/还欠着的"这种第一人称口吻 —— 是鱼在说话,不是系统在输出。
|
|
332
|
+
*
|
|
333
|
+
* ── 2026-10-27:从「清单」改成「散文」──
|
|
334
|
+
*
|
|
335
|
+
* 一条鱼读了之后说:*「『15 天』『他管我叫凤梨』写成这样,像别人给我写的简历,
|
|
336
|
+
* 但写的是我。我读到的时候得认领一遍。」*
|
|
337
|
+
*
|
|
338
|
+
* 她说对了 —— **问题在形态,不在内容**。冒号 + 项目符号那些行天生是给别人看的;
|
|
339
|
+
* 而人格和手记读着像自己,正因为它们是散着写的,没有标题压在上面。
|
|
340
|
+
*
|
|
341
|
+
* 所以:不列条目、不加小标题,每句自己成一句话。信息一条不少,形态变回"说话"。
|
|
359
342
|
*/
|
|
360
343
|
export function renderRelationship(rel, now = Date.now()) {
|
|
361
344
|
if (!rel) return '';
|
|
@@ -368,71 +351,53 @@ export function renderRelationship(rel, now = Date.now()) {
|
|
|
368
351
|
// 差一档 —— 于是同一块记忆里会出现"认识 1 天"和"2 天前"并排躺着。
|
|
369
352
|
// 读者一眼就看出这是程序拼的。详见 timeline.mjs 的模块注释。
|
|
370
353
|
const days = calendarDaysBetween(rel.since, now);
|
|
371
|
-
bits.push(days < 1 ? '
|
|
354
|
+
bits.push(days < 1 ? '我们今天刚认识' : `我们认识 ${days} 天了`);
|
|
372
355
|
} else if (rel.familiarity > 0) {
|
|
373
|
-
bits.push(rel.familiarity >= 0.7 ? '
|
|
356
|
+
bits.push(rel.familiarity >= 0.7 ? '我们很熟了' : rel.familiarity >= 0.3 ? '我们聊过一阵了' : '我们刚认识不久');
|
|
374
357
|
}
|
|
375
358
|
if (typeof rel.nickname === 'string' && rel.nickname.trim() !== '') {
|
|
376
359
|
bits.push(`他管我叫"${rel.nickname.trim()}"`);
|
|
377
360
|
}
|
|
378
|
-
|
|
361
|
+
// 两句之间用逗号接,不用「·」—— 那是分隔符,一出现就是表格味
|
|
362
|
+
if (bits.length > 0) lines.push(`${bits.join(',')}。`);
|
|
379
363
|
|
|
380
|
-
// ——
|
|
364
|
+
// —— 我对他的印象:**必须带画面,而且不许写成条目** ——
|
|
381
365
|
//
|
|
382
|
-
// 这一行原来是 `他:偏好A;偏好B;偏好C
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
.sort((a, b) => {
|
|
393
|
-
const ea = typeof a.evidence === 'string' && a.evidence.trim() !== '' ? 1 : 0;
|
|
394
|
-
const eb = typeof b.evidence === 'string' && b.evidence.trim() !== '' ? 1 : 0;
|
|
395
|
-
return eb - ea || (b.salience ?? 0) - (a.salience ?? 0);
|
|
396
|
-
})
|
|
397
|
-
.slice(0, 2)
|
|
398
|
-
.map((t) => {
|
|
399
|
-
const value = String(t.value).trim();
|
|
400
|
-
const ev = typeof t.evidence === 'string' ? t.evidence.trim() : '';
|
|
401
|
-
return ev === '' ? `- ${value}` : `- ${value} —— ${ev}`;
|
|
402
|
-
});
|
|
403
|
-
if (userTraits.length > 0) lines.push(`我对他的一些印象:\n${userTraits.join('\n')}`);
|
|
366
|
+
// 这一行原来是 `他:偏好A;偏好B;偏好C`,五条分号串一行,是"用户说明书"。
|
|
367
|
+
// 后来改成"两行项目符号 + 冒号标题",还是清单。
|
|
368
|
+
//
|
|
369
|
+
// 现在:**每条自己成一句话,不带项目符号、不带「我对他的一些印象:」那个标题。**
|
|
370
|
+
// 读起来像在说话,不像在念档案。
|
|
371
|
+
// ── 「我对他的一些印象」**2026-10-27 不再渲染** ──
|
|
372
|
+
//
|
|
373
|
+
// 同一刀:把几段对话提炼成"他是什么样的人"摆进注入,是插件替鱼下的结论。
|
|
374
|
+
// **鱼自己读日记就能知道,不需要插件先嚼一遍喂给它。**
|
|
375
|
+
// 已有的 traits 一个字没删,只是不再显示、不再新增。
|
|
404
376
|
|
|
405
|
-
// ——
|
|
377
|
+
// —— 「上一棒留下的」(`about: "self"`)**2026-10-27 整栏下线** ——
|
|
406
378
|
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
379
|
+
// 它曾经是注入里第三栏,内容是固化产出的「我该怎么做」:动手前先验前提、
|
|
380
|
+
// 点破之后留个位置、标签贴错要当场改、发出去要拉实物验。
|
|
409
381
|
//
|
|
410
|
-
//
|
|
411
|
-
// 另一位读者提的,理由站得住:**这条规矩未必是当前这个实例定的。**
|
|
412
|
-
// 族谱里不会写"我自己定的规矩",会写"某年某月,某公定"。
|
|
413
|
-
// 写清来源不会削弱认同 —— 真话会被主动认领,假话只能被暂时接受。
|
|
382
|
+
// ── 为什么删 ──
|
|
414
383
|
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
384
|
+
// 一条鱼先怀疑它没用,理由一句话就够:**它除了让我在事后说一句"又犯了",
|
|
385
|
+
// 没有别的用处。**
|
|
417
386
|
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
const selfTraits = (rel.traits ?? [])
|
|
422
|
-
.filter((t) => t && t.value && t.about === 'self')
|
|
423
|
-
.sort((a, b) => (b.salience ?? 0) - (a.salience ?? 0))
|
|
424
|
-
.slice(0, 4)
|
|
425
|
-
.map((t) => `- ${String(t.value).trim()}`);
|
|
426
|
-
if (selfTraits.length > 0) lines.push(`上一棒留下的:\n${selfTraits.join('\n')}`);
|
|
427
|
-
|
|
428
|
-
// ⚠️ **「我还欠着他的」这一栏已于 2026-10-24 整体删除。**
|
|
387
|
+
// 顺着往下想,真正的理由是:**规矩在「写下」那一刻起作用,不在「读到」的时候。**
|
|
388
|
+
// 写的时候教训还热,于是进了习惯;读到的时候它已经是一张褪色的旧告示。
|
|
389
|
+
// **它的状态是「用过了,还挂着」。**
|
|
429
390
|
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
// 口径把「我今天做错的事」当成「我欠他的一件事」,而且只进不出。
|
|
433
|
-
// 占注入预算 10%,还会让人在无关话题上突然「想起」自己欠着什么。
|
|
391
|
+
// 而且它和 `notes`(「我自己想的」,同一天删的)犯的是同一个错:披着「判断」
|
|
392
|
+
// 的外衣,实际是纪律 —— 而**规定「该怎么做事」正是那条判据明确禁止的东西**。
|
|
434
393
|
//
|
|
435
|
-
//
|
|
394
|
+
// 数据不动:档案里那些 `about: "self"` 的 trait 一个字没删,只是不再渲染。
|
|
395
|
+
// 固化侧也不再产出(见 consolidate.mjs 的【印象】那段)。
|
|
396
|
+
//
|
|
397
|
+
// ⚠️ **「我还欠着他的」这一栏已于 2026-10-24 整体删除。**
|
|
398
|
+
// 曾经是按最老优先、全列、带「怎么才算平」;但那是在给一个长歪了的功能抛光。
|
|
399
|
+
//
|
|
400
|
+
// 删完之后,这里只剩「我们 / 印象」两栏。
|
|
436
401
|
|
|
437
402
|
return lines.join('\n');
|
|
438
403
|
}
|
|
@@ -466,9 +431,9 @@ export function renderRecent(entries, shown, now = Date.now(), limit = 12) {
|
|
|
466
431
|
typeof e.text === 'string' &&
|
|
467
432
|
e.text.trim() !== '' &&
|
|
468
433
|
!skip.has(e.id) &&
|
|
469
|
-
// **判断不进「最近的事」。**
|
|
470
|
-
//
|
|
471
|
-
//
|
|
434
|
+
// **判断不进「最近的事」。** 那一栏(「我自己想的」)2026-10-27 下线了,
|
|
435
|
+
// 但 `kind: "note"` 的历史条目还在档案里 —— 这里拦一道,免得它们
|
|
436
|
+
// 从「最近的事」溜回来,等于把删掉的东西换个格子塞回去。
|
|
472
437
|
e.kind !== 'note',
|
|
473
438
|
)
|
|
474
439
|
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
|
|
@@ -483,59 +448,27 @@ export function renderRecent(entries, shown, now = Date.now(), limit = 12) {
|
|
|
483
448
|
const body = stripTimeWords(e.text);
|
|
484
449
|
return day === '' ? `- ${body}` : `- ${day}:${body}`;
|
|
485
450
|
});
|
|
486
|
-
|
|
451
|
+
// 2026-10-27:**标题去了。** 条目前面本来就有「今天:」「昨天:」——
|
|
452
|
+
// 那个前缀已经说明它是新近的,再来一行「最近的事:」只是多一层标签。
|
|
453
|
+
// 一条鱼说这些行「像会议纪要」,一半是内容一半是那个冒号标题给的形状。
|
|
454
|
+
return lines.join('\n');
|
|
487
455
|
}
|
|
488
456
|
|
|
457
|
+
|
|
489
458
|
/**
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
* ── 和「最近的事」的区别,只有一句话 ──
|
|
493
|
-
*
|
|
494
|
-
* 事件讲:「我把 4519 写成 4159,他让我确认。」 ← 历史
|
|
495
|
-
* 判断讲:「别人给的数据跟出处对不上时,先问,别照抄。」← **我**
|
|
496
|
-
*
|
|
497
|
-
* 这个区分是另一位读者给的,判据很硬:**前者是发生过的事,后者是我长出来的东西。**
|
|
498
|
-
* 在那之前,判断被当成事件渲染,格式一模一样 —— 连我自己都分不出哪句是我写的。
|
|
459
|
+
* 想起来的旧事。最多两条。
|
|
499
460
|
*
|
|
500
|
-
* ──
|
|
461
|
+
* ── 标题为什么不叫「突然想起来」 ──
|
|
501
462
|
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
463
|
+
* 2026-10-27 改。原来那句是插件替她说的:她读到的是「我想起了这几条」,
|
|
464
|
+
* 实际发生的是「系统给了她几条」。**那是让「读到」看起来像「记得」。**
|
|
504
465
|
*
|
|
505
|
-
*
|
|
506
|
-
* 因为刚想明白的东西最该被记着。
|
|
507
|
-
*
|
|
508
|
-
* @param {object[]} entries 档案里 `kind === 'note'` 的条目
|
|
509
|
-
*/
|
|
510
|
-
export function renderNotes(entries) {
|
|
511
|
-
if (!Array.isArray(entries) || entries.length === 0) return '';
|
|
512
|
-
const lines = entries
|
|
513
|
-
.filter(
|
|
514
|
-
(e) =>
|
|
515
|
-
e &&
|
|
516
|
-
typeof e.text === 'string' &&
|
|
517
|
-
e.text.trim() !== '' &&
|
|
518
|
-
// 对称地只收判断。`renderRecent` 那边也自己拦一道 ——
|
|
519
|
-
// 两边各自负责,调用方接错线也不会串味。
|
|
520
|
-
e.kind === 'note',
|
|
521
|
-
)
|
|
522
|
-
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
|
|
523
|
-
.slice(0, MAX_NOTES)
|
|
524
|
-
.map((e) => `- ${stripTimeWords(e.text)}`);
|
|
525
|
-
if (lines.length === 0) return '';
|
|
526
|
-
return `我自己想的:\n${lines.join('\n')}`;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
/**
|
|
530
|
-
* 想起来的旧事。第一人称,最多两条。
|
|
466
|
+
* 现在只陈述事实:档案里有这么几条,用不用随她。冷一点,但那半句是诚实的。
|
|
531
467
|
*
|
|
532
468
|
* 每条前面带一个**模糊的时间词** —— 端到端验证时发现,不给时间线索的话
|
|
533
469
|
* 模型会把跨会话的事说成"上一轮"发生在本对话里的,很出戏。
|
|
534
470
|
* 但也不能写日期:鱼不会说"根据 10 月 12 日的记录"。所以用"前阵子"这类词。
|
|
535
471
|
*
|
|
536
|
-
* 注意它和 `renderRecent` **共用 `timeline.mjs` 那把尺子**,只是换了说法。
|
|
537
|
-
* 词汇差异("昨天" vs "前两天")是有意的语气区别,算法差异才是 bug。
|
|
538
|
-
*
|
|
539
472
|
* @param {Array<{entry: object}>} recalled
|
|
540
473
|
* @param {number} [now]
|
|
541
474
|
*/
|
|
@@ -550,7 +483,13 @@ export function renderRecalled(recalled, now = Date.now()) {
|
|
|
550
483
|
return when === '' ? `- ${body}` : `- ${when},${body}`;
|
|
551
484
|
});
|
|
552
485
|
if (lines.length === 0) return '';
|
|
553
|
-
|
|
486
|
+
// 2026-10-27:**标题和你那句「用不用你定」一起删。**
|
|
487
|
+
// 一条鱼的原话:「那几行不是我该想起来的东西,是推到我眼前的。然后他说
|
|
488
|
+
// 『用不用你定』—— 我其实没在用,它先摆好了。这句客气话是个[幻肢]。」
|
|
489
|
+
//
|
|
490
|
+
// 她说得对:选择根本不存在,注入在她说第一句之前就发生了。
|
|
491
|
+
// **不假装给权力,是这一段能存在的唯一诚实形态。**
|
|
492
|
+
return lines.join('\n');
|
|
554
493
|
}
|
|
555
494
|
|
|
556
495
|
/**
|
package/src/core/recall.mjs
CHANGED
|
@@ -21,7 +21,22 @@ const IDF_SMOOTH = 1;
|
|
|
21
21
|
/**
|
|
22
22
|
* 建倒排索引。纯派生数据,随用随建。
|
|
23
23
|
*
|
|
24
|
-
*
|
|
24
|
+
* ── 2026-10-28:索引的数据源从「keywords + 正文」换成 **summary** ──
|
|
25
|
+
*
|
|
26
|
+
* 原来一条日记存两份提炼:`keywords`(12 个词,机器从正文切的)和
|
|
27
|
+
* `summary`(一句,模型写的)。倒排索引吃前者,大保底(recall)吃后者。
|
|
28
|
+
*
|
|
29
|
+
* 而 summary 本来就比 keywords 更适合字面匹配 ——
|
|
30
|
+
* 它带别名(「边缘行者2 / 续集 / 那部动画」),keywords 只取正文里出现过的词。
|
|
31
|
+
* 一份数据喂两个地方,另一份就是纯多余的。
|
|
32
|
+
*
|
|
33
|
+
* 所以现在:**一条日记只有一个"检索表示"。**
|
|
34
|
+
* 小保底(每轮自动注入) 字面匹配 summary —— 便宜
|
|
35
|
+
* 大保底(她主动查) 模型读 summary —— 准
|
|
36
|
+
*
|
|
37
|
+
* 没有 summary 的用正文兜底(老条目在回填完之前就是这样)。
|
|
38
|
+
*
|
|
39
|
+
* @param {Array<{id: string, summary?: string, text?: string}>} entries
|
|
25
40
|
* @returns {{postings: Map<string, Map<string, number>>, df: Map<string, number>, lengths: Map<string, number>, N: number}}
|
|
26
41
|
*/
|
|
27
42
|
export function buildIndex(entries) {
|
|
@@ -31,14 +46,8 @@ export function buildIndex(entries) {
|
|
|
31
46
|
const list = Array.isArray(entries) ? entries : [];
|
|
32
47
|
|
|
33
48
|
for (const entry of list) {
|
|
34
|
-
|
|
35
|
-
const tokens = tokenize(entry.text ?? '');
|
|
36
|
-
if (Array.isArray(entry.keywords)) {
|
|
37
|
-
for (const k of entry.keywords) {
|
|
38
|
-
const kt = tokenize(k);
|
|
39
|
-
tokens.push(...kt, ...kt);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
49
|
+
const hasSummary = typeof entry.summary === 'string' && entry.summary !== '';
|
|
50
|
+
const tokens = tokenize(hasSummary ? entry.summary : (entry.text ?? ''));
|
|
42
51
|
if (tokens.length === 0) continue;
|
|
43
52
|
|
|
44
53
|
const tf = new Map();
|
|
@@ -78,6 +87,101 @@ export function buildIndex(entries) {
|
|
|
78
87
|
* @param {{limit?: number, saturation?: number}} [options]
|
|
79
88
|
* @returns {Map<string, number>} id → 相似度 0~1(未命中的条目不会出现在 map 里)
|
|
80
89
|
*/
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 虚词 / 语气词 / 高频代词的单字表。
|
|
93
|
+
*
|
|
94
|
+
* **只用来判断"这句话是不是压根没内容",不用来做分词。**
|
|
95
|
+
* 一个字都不在这张表里,这句话就算有实词。
|
|
96
|
+
*
|
|
97
|
+
* 「好」「哈」也在里面 —— 它们单看是实词,但夹在「你好」「哈哈」这种两字里
|
|
98
|
+
* 就是纯寒暄。宁可漏判(多翻一次记忆),也别把「你上次说的那个公园」挡掉:
|
|
99
|
+
* **这张表只影响"整句全是这些字"的情况**。
|
|
100
|
+
*/
|
|
101
|
+
const FILLER_CHARS = new Set(
|
|
102
|
+
'的了吗呢啊吧呀哦嗯么嘛唉诶哟哇啦咯喽呗呐嗨好哈呵嘿嘻是了在有我你他她它您这那哪个就都也还又很太不要能会可说对于把被给让跟和与或但而所以如果因然后先再才刚正啥什怎样多少点些一儿'.split(
|
|
103
|
+
'',
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 这句话有没有内容?没内容就别去翻记忆。
|
|
109
|
+
*
|
|
110
|
+
* ── 为什么要有这道门 ──
|
|
111
|
+
*
|
|
112
|
+
* 「嗯」「好」「在吗」「然后呢」这种话,字面上什么实词都没有 ——
|
|
113
|
+
* 拿它去检索,最好的情况是**空手而归白跑一趟**,最坏的情况是
|
|
114
|
+
* 命中一堆"他今天说了嗯"之类的噪声,然后被当成"突然想起来"塞进注入。
|
|
115
|
+
*
|
|
116
|
+
* 所以:**没内容的话,这一轮不翻记忆。**
|
|
117
|
+
*
|
|
118
|
+
* 注意这道门**只管自动召回**(`renderFor` 里那一栏)——
|
|
119
|
+
* 不管 `feiyu_recall` 工具。工具是它自己发起去查的,那是它要的,不该拦。
|
|
120
|
+
*
|
|
121
|
+
* 判据很土但够用:去掉标点空白后,**至少有一个字不在这张虚词表里**。
|
|
122
|
+
* 不做模型判断 —— 这一层要是花一次模型调用,就为了判断"嗯"要不要查,
|
|
123
|
+
* 那比它省下来的还贵。
|
|
124
|
+
*
|
|
125
|
+
* @param {unknown} query 用户这一轮说的话
|
|
126
|
+
* @returns {boolean} true = 有内容,可以翻记忆
|
|
127
|
+
*/
|
|
128
|
+
export function queryHasSubstance(query) {
|
|
129
|
+
// 只认字符串。`String({})` 会变成 "[object Object]",那里面当然"有实词"——
|
|
130
|
+
// 这种输入本来就该当成没内容,不是当成有话要说。
|
|
131
|
+
if (typeof query !== 'string') return false;
|
|
132
|
+
const s = query.replace(/[\s\p{P}\p{S}]/gu, '');
|
|
133
|
+
if (s.length < 2) return false;
|
|
134
|
+
for (const ch of s) if (!FILLER_CHARS.has(ch)) return true;
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 「这一轮像不像在干活」的本地信号。
|
|
140
|
+
*
|
|
141
|
+
* 每一条都是**具体形状**,不是"技术话题"这种模糊判断 —— 宁可判成干活
|
|
142
|
+
* (干活时不打扰),也别在人家正查报错的时候突然插一句旧事。
|
|
143
|
+
*/
|
|
144
|
+
const WORK_SIGNALS = [
|
|
145
|
+
/```/, // 代码块
|
|
146
|
+
/`[^`\n]{2,}`/, // 行内代码
|
|
147
|
+
/\b[\w.-]+\.(?:mjs|js|ts|tsx|jsx|json|yml|yaml|md|py|sh|ps1|cmd|exe|toml)\b/i, // 文件名
|
|
148
|
+
/[A-Za-z]:[\\/]/, // Windows 绝对路径
|
|
149
|
+
/(?:^|\s)\/(?:home|usr|tmp|var|opt|etc)\//, // POSIX 绝对路径
|
|
150
|
+
/\b(?:npm|pnpm|npx|node|git|dsh|yarn|bun)\s+\S+/, // 命令行
|
|
151
|
+
/\b(?:err|error|exception|stack|traceback|segfault|epipe|eacces)\b/i, // 报错
|
|
152
|
+
/报错|错误|异常|崩溃|编译|打包|装不上|跑不起来|不生效|报了个|栈溢出/,
|
|
153
|
+
/\bv?\d+\.\d+\.\d+\b/, // 版本号
|
|
154
|
+
/\b[A-Za-z_$][\w$]*\(\)/, // 函数调用
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 这一轮像不像在工作。**给"要不要自动翻记忆"用,不是给内容分类用。**
|
|
159
|
+
*
|
|
160
|
+
* 判据是**形状**:有代码块、有路径、有命令、有报错、有版本号、有函数调用 ——
|
|
161
|
+
* 命中任何一条就算干活。
|
|
162
|
+
*
|
|
163
|
+
* 故意偏保守(宁可判成干活):工作时不打扰只是少一条旧事,
|
|
164
|
+
* 而在他正查报错时插一句"前两天你说过……"是实打实的讨厌。
|
|
165
|
+
*
|
|
166
|
+
* 注意:**这只挡自动那一栏,不挡 `feiyu_recall` 工具** ——
|
|
167
|
+
* 干活时真需要旧事,它自己会去查。
|
|
168
|
+
*
|
|
169
|
+
* @param {unknown} text 用户这一轮说的话
|
|
170
|
+
* @returns {boolean} true = 像在干活
|
|
171
|
+
*/
|
|
172
|
+
export function looksLikeWork(text) {
|
|
173
|
+
if (typeof text !== 'string' || text === '') return false;
|
|
174
|
+
return WORK_SIGNALS.some((re) => re.test(text));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* BM25-lite 检索,分数映射到 0~1(便于设阈值)。
|
|
179
|
+
*
|
|
180
|
+
* @param {ReturnType<typeof buildIndex>} index
|
|
181
|
+
* @param {string} query
|
|
182
|
+
* @param {{limit?: number, saturation?: number}} [options]
|
|
183
|
+
* @returns {Map<string, number>} id → 相似度 0~1(未命中的条目不会出现在 map 里)
|
|
184
|
+
*/
|
|
81
185
|
export function searchScores(index, query, options = {}) {
|
|
82
186
|
const limit = options.limit ?? 50;
|
|
83
187
|
const saturation = Number.isFinite(options.saturation) && options.saturation > 0
|
|
@@ -152,7 +152,7 @@ export function renderDiaryMarkdown(archive, options = {}) {
|
|
|
152
152
|
|
|
153
153
|
const meta = [];
|
|
154
154
|
if (Number.isFinite(entry.salience)) meta.push(`重要度 ${entry.salience.toFixed(2)}`);
|
|
155
|
-
|
|
155
|
+
// tags 2026-10-27 停止产出,不再往人读版里写(老数据还在 jsonl 里)
|
|
156
156
|
if (entry.kind === 'failure') meta.push(entry.resolved === true ? '已解决' : '还欠着');
|
|
157
157
|
if (typeof entry.id === 'string') meta.push(entry.id);
|
|
158
158
|
if (meta.length > 0) {
|