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.
@@ -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, selectMemories, searchScores, queryHasSubstance, looksLikeWork, DEFAULT_RECALL_OPTIONS } from '../core/recall.mjs';
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
- const { chosen } = chatty
976
- ? selectMemories(recallPool, index, query, s, s.turn, {
977
- highSim: config.highSim,
978
- // 闲聊时最多给两条候选 —— **给两条,它用哪条自己定,或者一条都不用。**
979
- // 只给一条等于系统替它说了「你就该想起这个」。
980
- maxShow: Math.min(2, config.maxShowPerTurn),
981
- wanderChance: config.wanderChance,
982
- // 旧事的随机池门槛。注意下限降到 0.15 是为了跟"降门槛"一致 ——
983
- // 门槛降到 0.15 却在这里卡 0.25,等于白降。
984
- minSalience: Math.max(0.15, config.minSalience),
985
- now: Date.now(),
986
- })
987
- : { chosen: [] };
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: `想不起来的时候,用 ${RECALL_TOOL_NAME} 回想一下 —— 用当时的词,越具体越准。`,
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
- const pool = all.slice(-400);
2017
- if (all.length > pool.length) log(`挑往事:${all.length} 条只取最近 ${pool.length} 条`);
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 空格 一句索引」。有些索引很粗(正文截断出来的),别嫌弃。\n' +
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
- messages: [{ role: 'user', content: [{ type: 'text', text: `她问:${query}\n\n索引:\n${lines.join('\n')}` }] }],
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
- async execute(args) {
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 lines = [];
2375
+ const pickedEntries = [];
2198
2376
  let used = 0;
2199
- const now = Date.now();
2200
- for (const { entry } of ranked) {
2377
+ for (const { entry, sim } of ranked) {
2378
+ // note(判断那一栏已下线)不进来 —— 让规矩从工具溜回来,等于没删干净
2379
+ if (entry.kind === 'note') continue;
2201
2380
  // 跟注入走同一个隐私闸 —— 历史条目里可能还有没洗过的东西
2202
- const body = stripTimeWords(scrub(entry.text, { thirdParty: false }).text);
2203
- const when = softWhen(entry.created_at, now);
2204
- const line = when === '' ? body : `${when}:${body}`;
2205
- if (used + line.length > MAX_CHARS && lines.length > 0) break;
2206
- lines.push(`- ${line}`);
2207
- used += line.length;
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
- log(`检索「${query.slice(0, 20)}」:返回 ${lines.length} 条`);
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: lines.length,
2214
- text: lines.join('\n'),
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