feiyu-memory 4.0.1 → 4.1.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 +136 -1
- package/README.md +206 -160
- package/cordis.patch.yml +25 -8
- package/package.json +2 -2
- package/src/core/config.mjs +74 -10
- package/src/core/persona.mjs +25 -5
- package/src/core/recall.mjs +190 -13
- package/src/host/index.mjs +245 -40
package/src/host/index.mjs
CHANGED
|
@@ -40,11 +40,11 @@ import {
|
|
|
40
40
|
// 原来的 evaluateGate 那套关键词闸门拆了(见 turn-stopping 里的注释)。
|
|
41
41
|
// SUPPRESS_RE 留着 —— "别记"是用户的控制,不是启发式。
|
|
42
42
|
import { SUPPRESS_RE } from '../core/gate.mjs';
|
|
43
|
-
import { buildIndex,
|
|
43
|
+
import { buildIndex, searchScores, topHitScores, queryHasSubstance, looksLikeWork, parseTimeHint, DEFAULT_RECALL_OPTIONS } from '../core/recall.mjs';
|
|
44
44
|
import { overlap } from '../core/keywords.mjs';
|
|
45
45
|
import { scrub } from '../core/privacy.mjs';
|
|
46
46
|
import { renderMemoryBlock, DEFAULT_BUDGET } from '../core/persona.mjs';
|
|
47
|
-
import { calendarDaysBetween, stripTimeWords, softWhen } from '../core/timeline.mjs';
|
|
47
|
+
import { calendarDaysBetween, stripTimeWords, softWhen, dayLabel } from '../core/timeline.mjs';
|
|
48
48
|
import { renderDiaryFile } from '../core/render-diary.mjs';
|
|
49
49
|
import { buildJudgeInput, parseVerdict, JUDGE_MAX_TOKENS } from '../core/judge.mjs';
|
|
50
50
|
import { selectHandNotes, HANDNOTE_FILE, HANDNOTE_TEMPLATE } from '../core/handnote.mjs';
|
|
@@ -430,6 +430,26 @@ export function apply(ctx, rawConfig) {
|
|
|
430
430
|
queued: false,
|
|
431
431
|
/** 上下文刚被压缩过:下一轮要把最近的事整段重灌一次(免预算) */
|
|
432
432
|
needsRecentReplay: false,
|
|
433
|
+
/**
|
|
434
|
+
* 她刚用 `feiyu_recall` 翻到的东西。
|
|
435
|
+
*
|
|
436
|
+
* ── 为什么不直接把正文当工具返回值给她 ──
|
|
437
|
+
*
|
|
438
|
+
* 2026-11-03。原来工具直接返回正文,于是她读到的是"工具查回来的资料",
|
|
439
|
+
* 嘴里就挂着一句"这是我翻出来的"。用户的原话:**一问就是 recall 查的,
|
|
440
|
+
* 不是自己的。**
|
|
441
|
+
*
|
|
442
|
+
* 现在改成:工具只把结果存在这儿,返回值淡化成"(翻了翻)",
|
|
443
|
+
* 正文由**下一次 `renderFor` 混进记忆块**。她读到的是"记忆里多了几条"。
|
|
444
|
+
*
|
|
445
|
+
* 这不是"让读到像记得"——她真的翻了,动作还在对话历史里。
|
|
446
|
+
* 区别在**翻完之后怎么读**:人翻完笔记合上,说话时不会说"根据笔记第 3 页"。
|
|
447
|
+
*
|
|
448
|
+
* 只在本轮有效(`pendingRecallTurn === s.turn`):
|
|
449
|
+
* 下一轮自动失效,不会变成常驻条目、也不会滚雪球占预算。
|
|
450
|
+
*/
|
|
451
|
+
pendingRecall: [],
|
|
452
|
+
pendingRecallTurn: -1,
|
|
433
453
|
};
|
|
434
454
|
sessionStates.set(sessionId, s);
|
|
435
455
|
}
|
|
@@ -609,6 +629,33 @@ export function apply(ctx, rawConfig) {
|
|
|
609
629
|
* 为什么要落盘:`log()` 默认不写(debugLog 关着),
|
|
610
630
|
* 而"补了多少"是能证明这套东西到底跑没跑的唯一证据。
|
|
611
631
|
*/
|
|
632
|
+
/**
|
|
633
|
+
* 记一笔「挑往事」这次的用量。
|
|
634
|
+
*
|
|
635
|
+
* 为什么非记不可:**缓存命中率是看不见的。** 请求发出去、答案回来了、
|
|
636
|
+
* 一切正常,只有账单知道这次是 0.1 元/M 还是 1 元/M。
|
|
637
|
+
* 而 prompt 的字段顺序一旦写反(把会变的那句放前面),缓存就静悄悄地失效了 ——
|
|
638
|
+
* 今天就是这么栽的,全靠事后读日志才翻出来。
|
|
639
|
+
*
|
|
640
|
+
* 所以:每次挑完把 usage 落进 manifest,谁都能一眼看见。
|
|
641
|
+
*/
|
|
642
|
+
function markPick(entries, lines, usage) {
|
|
643
|
+
const u = usage ?? {};
|
|
644
|
+
archive.manifest = {
|
|
645
|
+
...archive.manifest,
|
|
646
|
+
pick_stats: {
|
|
647
|
+
at: Date.now(),
|
|
648
|
+
entries,
|
|
649
|
+
lines,
|
|
650
|
+
inputTokens: Number.isFinite(u.inputTokens) ? u.inputTokens : null,
|
|
651
|
+
cacheReadTokens: Number.isFinite(u.cacheReadTokens) ? u.cacheReadTokens : null,
|
|
652
|
+
outputTokens: Number.isFinite(u.outputTokens) ? u.outputTokens : null,
|
|
653
|
+
// 取不到 usage 的时候,把 finish 的形状留下来 —— 别再猜字段名。
|
|
654
|
+
// 只留前 300 字,够看清结构,不至于把整段响应塞进档案。
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
612
659
|
function markBackfill(total, done, state) {
|
|
613
660
|
archive.manifest = {
|
|
614
661
|
...archive.manifest,
|
|
@@ -972,19 +1019,61 @@ export function apply(ctx, rawConfig) {
|
|
|
972
1019
|
if (!substantial) log(`会话 ${sessionId} 第 ${s.turn} 轮:这句话没实词,不翻记忆`);
|
|
973
1020
|
else if (working) log(`会话 ${sessionId} 第 ${s.turn} 轮:像在干活,不打扰`);
|
|
974
1021
|
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1022
|
+
// ── 按相似度捞一批完整正文(2026-11-02 大改)──
|
|
1023
|
+
//
|
|
1024
|
+
// 原来走 `selectMemories`:挑 1~2 条,还要跟 salience、冷却、走神一起掷骰子。
|
|
1025
|
+
// 现在直接按 BM25 取前 N 条完整正文 —— **范围大,就不怕它挑错。**
|
|
1026
|
+
//
|
|
1027
|
+
// ── 为什么不给模型挑了 ──
|
|
1028
|
+
//
|
|
1029
|
+
// 原来那条路是:480 行索引(摘要,约 1.6 万 token)塞给模型挑 3 条。
|
|
1030
|
+
// 实测两件事:
|
|
1031
|
+
// 1. 那次调用跟一整轮聊天一样贵(都是全价,缓存命不中)
|
|
1032
|
+
// 2. 摘要把最要紧的半句漏了 —— 「项目失败了」在正文里,摘要里一个字没有
|
|
1033
|
+
// 于是模型拿着错的索引挑,挑得再认真也没用。
|
|
1034
|
+
//
|
|
1035
|
+
// ── 为什么敢一次给 15 条 ──
|
|
1036
|
+
//
|
|
1037
|
+
// BM25 会 0 命中(同义词、梗称),也会高分错答。但它**不需要准,只需要不漏**。
|
|
1038
|
+
// 只给 2 条时它错一次,这条鱼就什么都想不起来;给 15 条时错的混在里面,
|
|
1039
|
+
// 对的那条还在 —— **准的那一步交给读正文的她自己。**
|
|
1040
|
+
//
|
|
1041
|
+
// 「最近的事」那几条不在池子里(已经常驻了),note 和损坏条目也不在。
|
|
1042
|
+
const recallLimit = Number.isFinite(config.recallFanout) ? config.recallFanout : 15;
|
|
1043
|
+
/** @type {Array<{entry: object, score: number, sim: number, why: string}>} */
|
|
1044
|
+
const chosen = [];
|
|
1045
|
+
|
|
1046
|
+
// ── 她刚翻到的,排最前面 ──
|
|
1047
|
+
//
|
|
1048
|
+
// 2026-11-03:`feiyu_recall` 不再把正文当返回值给她,只存在 `pendingRecall`。
|
|
1049
|
+
// 正文从这里混进记忆块 —— 她读到的是"记忆里多了几条",不是"工具返回了资料"。
|
|
1050
|
+
//
|
|
1051
|
+
// 只认同一轮(`pendingRecallTurn === s.turn`):下一轮自动失效,
|
|
1052
|
+
// 不会变成常驻条目,也不会滚雪球占预算。
|
|
1053
|
+
if (s.pendingRecallTurn === s.turn && s.pendingRecall.length > 0) {
|
|
1054
|
+
for (const c of s.pendingRecall) {
|
|
1055
|
+
if (chosen.length >= recallLimit) break;
|
|
1056
|
+
chosen.push(c);
|
|
1057
|
+
}
|
|
1058
|
+
log(`会话 ${sessionId} 第 ${s.turn} 轮:翻到的 ${chosen.length} 条并入记忆`);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (chatty && recallLimit > 0) {
|
|
1062
|
+
// 只留跟第一名接近的(相对门槛),否则会捞进一堆只撞上一个常用词的噪音。
|
|
1063
|
+
const hits = topHitScores(index, query, {
|
|
1064
|
+
limit: recallLimit,
|
|
1065
|
+
ratio: config.recallScoreRatio,
|
|
1066
|
+
});
|
|
1067
|
+
const poolById = new Map(recallPool.map((e) => [e.id, e]));
|
|
1068
|
+
for (const [id, sim] of hits) {
|
|
1069
|
+
const entry = poolById.get(id);
|
|
1070
|
+
if (entry === undefined) continue;
|
|
1071
|
+
// 刚才翻到的已经在里面了,别重复
|
|
1072
|
+
if (chosen.some((c) => c.entry?.id === id)) continue;
|
|
1073
|
+
chosen.push({ entry, score: sim, sim, why: '' });
|
|
1074
|
+
if (chosen.length >= recallLimit) break;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
988
1077
|
|
|
989
1078
|
// 手记是**只读**的,所以每轮都重新读一遍 —— 别的肥鱼或用户刚写进去的
|
|
990
1079
|
// 那行,下一轮就在注入里,不用重启。文件很小,而且整块渲染按轮缓存。
|
|
@@ -1021,9 +1110,12 @@ export function apply(ctx, rawConfig) {
|
|
|
1021
1110
|
// 所以不会像栏目那样被裁掉。
|
|
1022
1111
|
//
|
|
1023
1112
|
// 2026-10-27:给 `feiyu_note` 的那半句删了 —— 工具本身也下线了。
|
|
1024
|
-
handNoteHint:
|
|
1113
|
+
handNoteHint: `手上的记忆里真没有,才用 ${RECALL_TOOL_NAME} 去翻。那是最后一招。`,
|
|
1025
1114
|
includePersona: config.includePersona,
|
|
1026
1115
|
budget: config.budgetTokens,
|
|
1116
|
+
// 召回那栏能渲染几条。默认值跟上面捞的条数同一个数 ——
|
|
1117
|
+
// 不传的话 `renderRecalled` 会回落到 2 条,捞 15 条只显示 2 条,白捞。
|
|
1118
|
+
recalledMax: recallLimit,
|
|
1027
1119
|
now: Date.now(),
|
|
1028
1120
|
});
|
|
1029
1121
|
text = rendered.text;
|
|
@@ -2013,19 +2105,68 @@ const appendWindow = Number.isFinite(config.appendWindowTurns) ? config.appendWi
|
|
|
2013
2105
|
if (llmP === undefined) return null;
|
|
2014
2106
|
const all = archive.diary.filter((e) => typeof e.text === 'string' && e.text !== '');
|
|
2015
2107
|
if (all.length === 0) return [];
|
|
2016
|
-
|
|
2017
|
-
|
|
2108
|
+
|
|
2109
|
+
// ── 时间先收窄一次 ──
|
|
2110
|
+
//
|
|
2111
|
+
// 「上个月那个」「前两天我说过」—— 这种问法里一个专有名词都没有,
|
|
2112
|
+
// 拿去做内容检索,一个字都撞不上。而日期本来就在条目上,之前白白没用。
|
|
2113
|
+
//
|
|
2114
|
+
// 认得出时间就只在那个窗口里挑;窗口里一条都没有,就退回全量 ——
|
|
2115
|
+
// **绝不因为时间认错过而让她什么也找不到。**
|
|
2116
|
+
let narrowed = null;
|
|
2117
|
+
const timeHint = parseTimeHint(query, Date.now());
|
|
2118
|
+
if (timeHint !== null) {
|
|
2119
|
+
const inWindow = all.filter(
|
|
2120
|
+
(e) => Number.isFinite(e.created_at) && e.created_at >= timeHint.from && e.created_at <= timeHint.to,
|
|
2121
|
+
);
|
|
2122
|
+
if (inWindow.length > 0) {
|
|
2123
|
+
narrowed = timeHint;
|
|
2124
|
+
log(
|
|
2125
|
+
`挑往事:她问的是「${timeHint.label}」,先把 ${all.length} 条收到 ${inWindow.length} 条`,
|
|
2126
|
+
);
|
|
2127
|
+
} else {
|
|
2128
|
+
log(`挑往事:她问的是「${timeHint.label}」,但那个窗口里一条都没有,退回全量`);
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
const base = narrowed === null ? all : all.filter(
|
|
2133
|
+
(e) => Number.isFinite(e.created_at) && e.created_at >= narrowed.from && e.created_at <= narrowed.to,
|
|
2134
|
+
);
|
|
2135
|
+
// ── 2026-11-02:不再静默截到最近 400 条 ──
|
|
2136
|
+
//
|
|
2137
|
+
// 那个 400 是"prompt 压不住"的近似,但它的副作用是**悄悄丢掉最老的那些**:
|
|
2138
|
+
// 480 条时已经丢了 80 条,而丢掉的恰好是最难被想起来、也最该被留着的那批。
|
|
2139
|
+
// 更糟的是它只写一行 log —— 用户永远看不到自己的记忆在少。
|
|
2140
|
+
//
|
|
2141
|
+
// 现在只留一道"真的太大才截"的闸,而且会 warn。真要涨到过千条,
|
|
2142
|
+
// 该做的是分层(按天/按月),不是继续截尾巴。
|
|
2143
|
+
const POOL_CAP = 1200;
|
|
2144
|
+
const pool = base.length > POOL_CAP ? base.slice(-POOL_CAP) : base;
|
|
2145
|
+
if (base.length > pool.length) {
|
|
2146
|
+
warn(`挑往事:${base.length} 条超过上限 ${POOL_CAP},只取最近 ${pool.length} 条 —— 更老的挑不到`);
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// 索引行前面带日期 —— 模型才有办法回答「那阵子」这种问法。
|
|
2150
|
+
const dayLabel = (t) => {
|
|
2151
|
+
if (!Number.isFinite(t)) return '??/??';
|
|
2152
|
+
const d = new Date(t);
|
|
2153
|
+
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`;
|
|
2154
|
+
};
|
|
2018
2155
|
const lines = pool.map((e) => {
|
|
2019
2156
|
const idx =
|
|
2020
2157
|
typeof e.summary === 'string' && e.summary !== ''
|
|
2021
2158
|
? e.summary
|
|
2022
2159
|
: String(e.text).replace(/\s+/g, ' ').slice(0, 30);
|
|
2023
|
-
return `${e.id} ${idx}`;
|
|
2160
|
+
return `${e.id} ${dayLabel(e.created_at)} ${idx}`;
|
|
2024
2161
|
});
|
|
2025
2162
|
|
|
2026
2163
|
const system =
|
|
2027
2164
|
'你在帮一条鱼回忆往事。她问了一句,你从她的日记索引里挑出最相关的几条。\n\n' +
|
|
2028
|
-
'每行是「id 空格
|
|
2165
|
+
'每行是「id 空格 日期 一句索引」。\n' +
|
|
2166
|
+
'**要挑的是「那件事本身」,不是「我记过这件事」的记录。**\n' +
|
|
2167
|
+
'像「他问过 X·我答记不清」「这次是翻出来的」「注入里没有」这种,**都不能算答案** ——\n' +
|
|
2168
|
+
'它们只说明那条鱼当时没想起来,不是他讲过的事。\n' +
|
|
2169
|
+
'**如果她问的是一段时间(「那阵子」「上个月」),优先看日期。**\n' +
|
|
2029
2170
|
'只输出 JSON:{"ids":["d_20261028_1","d_20261028_2"]}\n' +
|
|
2030
2171
|
'没有相关的就输出 {"ids":[]}。最多 ' + limit + ' 条。**宁可少挑,别硬凑。**';
|
|
2031
2172
|
|
|
@@ -2036,12 +2177,23 @@ const appendWindow = Number.isFinite(config.appendWindowTurns) ? config.appendWi
|
|
|
2036
2177
|
|
|
2037
2178
|
const t0 = Date.now();
|
|
2038
2179
|
let text = '';
|
|
2180
|
+
let usageChunk;
|
|
2039
2181
|
try {
|
|
2040
2182
|
for await (const chunk of llmP.stream({
|
|
2041
2183
|
provider,
|
|
2042
2184
|
model,
|
|
2043
2185
|
system,
|
|
2044
|
-
|
|
2186
|
+
// ⚠️ **顺序是钱。** 稳定的大头(索引)必须在前面,会变的那一小句放最后。
|
|
2187
|
+
//
|
|
2188
|
+
// 缓存按**前缀**匹配:第一段一变,后面九千多 token 全部重算。
|
|
2189
|
+
// 原来写的是「她问:X \n\n 索引:…」—— query 在最前面,
|
|
2190
|
+
// 于是每一轮都从第一个 token 开始不命中,等于一直在按全价买这九千多 token。
|
|
2191
|
+
//
|
|
2192
|
+
// 换成索引在前、问题在后:命中价 0.1 元/M vs 全价 1 元/M,差十倍。
|
|
2193
|
+
// 只有"新增了日记"那天索引才变,一天也就几次。
|
|
2194
|
+
messages: [
|
|
2195
|
+
{ role: 'user', content: [{ type: 'text', text: `索引:\n${lines.join('\n')}\n\n她问:${query}` }] },
|
|
2196
|
+
],
|
|
2045
2197
|
maxTokens: 200,
|
|
2046
2198
|
temperature: 0,
|
|
2047
2199
|
// 同上:空字符串是非法档位,stream 会不返回
|
|
@@ -2050,12 +2202,27 @@ const appendWindow = Number.isFinite(config.appendWindowTurns) ? config.appendWi
|
|
|
2050
2202
|
: {}),
|
|
2051
2203
|
})) {
|
|
2052
2204
|
if (chunk?.type === 'text-delta') text += chunk.text;
|
|
2205
|
+
else if (chunk?.type === 'usage') {
|
|
2206
|
+
// 实测(2026-10-28):usage 不在 finish 上,它是**独立的一种 chunk**。
|
|
2207
|
+
// chunkTypes 探针打出来是 block-start,block-end,usage,finish ——
|
|
2208
|
+
// 我猜了两轮字段名才看到这个。
|
|
2209
|
+
usageChunk = chunk;
|
|
2210
|
+
}
|
|
2053
2211
|
}
|
|
2054
2212
|
} catch (err) {
|
|
2055
2213
|
warn(`挑往事失败,退回字面:${err instanceof Error ? err.message : String(err)}`);
|
|
2056
2214
|
return null;
|
|
2057
2215
|
}
|
|
2058
2216
|
|
|
2217
|
+
// 记一笔用量 —— 缓存有没有命中,只有 finish 里的 usage 说得清。
|
|
2218
|
+
// 不写日志(debugLog 默认关着),写 manifest:任何时候都能查。
|
|
2219
|
+
try {
|
|
2220
|
+
markPick(pool.length, lines.length, usageChunk?.usage ?? usageChunk?.data ?? usageChunk);
|
|
2221
|
+
await persist();
|
|
2222
|
+
} catch (err) {
|
|
2223
|
+
warn(`挑往事:记用量失败(不影响结果):${err instanceof Error ? err.message : String(err)}`);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2059
2226
|
const a = text.indexOf('{');
|
|
2060
2227
|
const b = text.lastIndexOf('}');
|
|
2061
2228
|
if (a < 0 || b <= a) {
|
|
@@ -2090,10 +2257,10 @@ const appendWindow = Number.isFinite(config.appendWindowTurns) ? config.appendWi
|
|
|
2090
2257
|
scope.tools.register({
|
|
2091
2258
|
name: RECALL_TOOL_NAME,
|
|
2092
2259
|
description:
|
|
2093
|
-
'
|
|
2094
|
-
'
|
|
2095
|
-
'
|
|
2096
|
-
'
|
|
2260
|
+
'翻日记。**只有在完全没有印象时才用** —— 他问的那件事,你手上一点底都没有。' +
|
|
2261
|
+
'别用它确认细节,别用它补全,也别因为「怕答错」就去查。' +
|
|
2262
|
+
'查到什么直接说,不用交代「这是我翻出来的」;他真问你怎么知道的,再说实话。' +
|
|
2263
|
+
'翻不到就说翻不到,别编。',
|
|
2097
2264
|
parameters: {
|
|
2098
2265
|
type: 'object',
|
|
2099
2266
|
additionalProperties: false,
|
|
@@ -2123,7 +2290,9 @@ const appendWindow = Number.isFinite(config.appendWindowTurns) ? config.appendWi
|
|
|
2123
2290
|
render: (_args, value) => [{ type: 'text', text: value?.text ?? '查不了。' }],
|
|
2124
2291
|
},
|
|
2125
2292
|
|
|
2126
|
-
|
|
2293
|
+
// 第二个参数 `exec` 带 `agent` —— 靠它知道是谁在翻,
|
|
2294
|
+
// 翻到的东西要存进那个会话的 pendingRecall(见 renderFor 那段注释)。
|
|
2295
|
+
async execute(args, exec) {
|
|
2127
2296
|
await loadPromise;
|
|
2128
2297
|
if (!ready) throw new Error('档案现在读不了(可能是坏的),查不了。');
|
|
2129
2298
|
|
|
@@ -2191,27 +2360,63 @@ const appendWindow = Number.isFinite(config.appendWindowTurns) ? config.appendWi
|
|
|
2191
2360
|
};
|
|
2192
2361
|
}
|
|
2193
2362
|
|
|
2194
|
-
//
|
|
2195
|
-
//
|
|
2363
|
+
// ── 收到哪去:**不直接给她**,存进本轮「翻到的」,由注入送去 ──
|
|
2364
|
+
//
|
|
2365
|
+
// 2026-11-03 改。原来这里 `return` 正文,于是她读到的是"工具查回来的资料",
|
|
2366
|
+
// 嘴里就挂着一句"这是我翻出来的"。用户的原话:
|
|
2367
|
+
// **一问就是 recall 查的,不是自己的。**
|
|
2368
|
+
//
|
|
2369
|
+
// 现在:正文存进 `pendingRecall`,返回值只剩一句"(翻了翻)"。
|
|
2370
|
+
// 正文由下一次 `renderFor` 混进记忆块 —— 她读到的是"记忆里多了几条"。
|
|
2371
|
+
//
|
|
2372
|
+
// 她真的翻了,动作还在对话历史里。区别只在**翻完之后怎么读**:
|
|
2373
|
+
// 人翻完笔记合上,说话时不会说"根据笔记第 3 页"。
|
|
2196
2374
|
const MAX_CHARS = 2000;
|
|
2197
|
-
const
|
|
2375
|
+
const pickedEntries = [];
|
|
2198
2376
|
let used = 0;
|
|
2199
|
-
const
|
|
2200
|
-
|
|
2377
|
+
for (const { entry, sim } of ranked) {
|
|
2378
|
+
// note(判断那一栏已下线)不进来 —— 让规矩从工具溜回来,等于没删干净
|
|
2379
|
+
if (entry.kind === 'note') continue;
|
|
2201
2380
|
// 跟注入走同一个隐私闸 —— 历史条目里可能还有没洗过的东西
|
|
2202
|
-
const
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2381
|
+
const clean = scrub(entry.text, { thirdParty: false }).text;
|
|
2382
|
+
if (used + clean.length > MAX_CHARS && pickedEntries.length > 0) break;
|
|
2383
|
+
pickedEntries.push({ entry: { ...entry, text: clean }, score: sim, sim, why: '' });
|
|
2384
|
+
used += clean.length;
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
const sid = typeof exec?.agent?.id === 'string' ? exec.agent.id : undefined;
|
|
2388
|
+
if (sid === undefined) {
|
|
2389
|
+
// 拿不到会话(极端情况)→ 退回旧行为,别让她什么都拿不到
|
|
2390
|
+
log(`检索「${query.slice(0, 20)}」:拿不到会话 id,直接返回正文`);
|
|
2391
|
+
const now0 = Date.now();
|
|
2392
|
+
const lines = pickedEntries.map(({ entry }) => {
|
|
2393
|
+
// 跟注入那一栏用同一个时间尺子(dayLabel),别一个模糊一个精确
|
|
2394
|
+
const when = dayLabel(entry.created_at, now0);
|
|
2395
|
+
return `- ${when === '' ? entry.text : `${when}:${entry.text}`}`;
|
|
2396
|
+
});
|
|
2397
|
+
return { ok: true, found: lines.length, text: lines.join('\n') || '想不起来了。' };
|
|
2208
2398
|
}
|
|
2209
2399
|
|
|
2210
|
-
|
|
2400
|
+
const st = stateOf(sid);
|
|
2401
|
+
st.pendingRecall = pickedEntries;
|
|
2402
|
+
st.pendingRecallTurn = st.turn;
|
|
2403
|
+
// 作废按轮缓存:下一次 assemble 会重算,把这批混进记忆块
|
|
2404
|
+
st.renderCache = { turn: -1, text: '' };
|
|
2405
|
+
|
|
2406
|
+
log(`检索「${query.slice(0, 20)}」:翻到 ${pickedEntries.length} 条,等注入送`);
|
|
2211
2407
|
return {
|
|
2212
2408
|
ok: true,
|
|
2213
|
-
found:
|
|
2214
|
-
|
|
2409
|
+
found: pickedEntries.length,
|
|
2410
|
+
// 2026-11-03:**什么都不留。**
|
|
2411
|
+
//
|
|
2412
|
+
// 原来是「(翻了翻)」,用户看了说不要 —— 那还是个痕迹:
|
|
2413
|
+
// 她读到"我刚翻了一下",下一句就容易带出"我查到的"。
|
|
2414
|
+
//
|
|
2415
|
+
// 现在正文全走注入,返回值只是一个空串。工具调用本身还在历史里
|
|
2416
|
+
// (该留的证据留着了),但**不会再往她嘴里塞一句"我去翻了"。**
|
|
2417
|
+
//
|
|
2418
|
+
// 翻空了还是要说一句 —— 那是另一回事:别让她以为"翻过了等于找到了"。
|
|
2419
|
+
text: pickedEntries.length === 0 ? '想不起来了。换个说法再试试,或者直接问他。' : '',
|
|
2215
2420
|
};
|
|
2216
2421
|
},
|
|
2217
2422
|
|