# 设计说明:为什么记忆透镜长这样 这份文档解释每一个设计决定背后的取舍。不是功能清单 —— 功能看 README。这里只回答「为什么不是另一种做法」。 --- ## 一、记忆由 agent 自己判断要不要记,而不是自动抽取 **备选方案**:每轮对话结束后跑一次抽取,把「值得记的」自动存进库。 **没选它,因为**: 自动抽取的问题不在于抽得准不准,而在于**抽错了没人负责**。抽取器把「嗯」「好的」「我看看」也存进库时,没有任何人会察觉 —— 库里多了一条噪音,检索质量下降一点点,用户感觉「这功能不太灵」,然后关掉它。整个过程没有报错、没有日志、没有可以追责的环节。 交给 agent 自己判断,代价是它每轮要多想一步(「这句话值得记吗」),好处是库里每一条都有一次明确的判断为它负责。工具调用是有痕迹的:能看见它记了什么、什么时候记的、为什么记。 这也解释了为什么工具只有三个(`remember` / `recall` / `forget`)—— 工具一多,agent 就会在「该用哪个」上花时间,而这些时间本来应该花在用户的问题上。 --- ## 二、注入有硬预算,超了就少注入几条,绝不截断单条 **备选方案**:把检索到的高分记忆全部注入,让模型自己挑。 **没选它,因为**:上下文是稀缺资源,无节制的注入是慢性自杀。20 条记忆 × 每条 120 字 = 2400 字,看着不多,但它**每轮都在**。会话进行到第 30 轮,这部分开销已经累积了 7 万字。 **更关键的是「怎么超」**:预算超了的时候,有两种处理方式 —— 截断单条正文,或者少注入几条。选了后者。 截断正文是危险的:一条记忆被截成「阿风在准备考研,这期间不要给他排…」,剩下的半句没了。模型读到一个残缺的约定,比读不到更糟 —— 它会以为约定只到「不要给他排」为止,然后自己猜后半句。而少注入一条完整的记忆,损失是明确的、可预期的。 所以挑选函数里有一行看起来很奇怪(四层化之后这段逻辑搬进了 `selectLayeredMemories` 的 `push`): ```js const next = picked.length === 0 ? BLOCK_HEADER.length + 1 + longMemoryLineLength(m, now) : chars + 1 + longMemoryLineLength(m, now); if (picked.length > 0 && next > maxChars) break; ``` 第一条无论多长都放进去(`picked.length === 0` 时不检查预算)—— 一条都不注入比超一点预算更糟。 而 `longMemoryLineLength` 这个函数存在的唯一理由,是**让预算估算和实际渲染共用同一个计算**。早期的实现是粗估(`text.length + tags.join('').length + 8`),结果每行少算 11 个字(前缀、括号、日期、后缀),预算 1200 实际注入 1338。这种偏差不会报错,只会让预算慢慢失效。 --- ## 三、注入分四层,而不是「按重要度取前 12 条」 **备选方案**:一个打分函数排出全库顺序,取前 N 条注入。这也是 0.3.0 之前一直在用的做法。 **没选它,因为**:它有一个说不出口的毛病 —— **排在 N 名以后的那些永远轮不到**。查询为空时(注入就是这种情况),分数只剩「权重 + 新近度」,排序长期稳定。库里有 28 条、预算只装得下 9 条时,后 19 条**每一次**都在队尾。它们没被归档、没被删除,面板上安安静静地列着,用户以为「她记得」,其实一条都没进过上下文。 这个失败是沉默的:没有报错,没有日志,注入块每次都正常渲染,只是内容永远是那 9 条。轮换名额(`injectRotateSlots`)缓解了一部分,但它只解决「冷门条目也能轮到」,解决不了另外两个更实际的问题: 1. **有些条目不该按重要度竞争。** 人设、称呼、「主人」这类东西,重要度打分不一定最高(一条「阿风生日 10 月 18 日」的权重可能只有 4),但它必须每轮都在。反过来,用户明确说过「按重要程度排上的那几条有的不用一直记着,比如我的生日」—— 他要的不是「重要的都在」,而是「该在的在」。 2. **最近发生的事必须有一席之地。** 用户的原话是「不能遗忘最近你们之间在干嘛」。只看分数,三天前那次深夜聊天会沉下去,而那恰恰是最该被提起的事。 所以改成四层,一个预算分给四层(`selectLayeredMemories`,`lib/core/long-memory.js`): | 层 | 挑什么 | 为什么 | | --- | --- | --- | | **固定层** | `pinned === true`,按权重降序 | 钉住是显式意图,不该被打分函数覆盖 | | **近期层** | 最近 `injectRecentDays` 天内有任何时间戳更新的 | 「最近你们之间在干嘛」 | | **话题层** | 用这次对话里**用户说过的话**做 BM25,命中才进 | 「去记忆库里找相关内容」 | | **兜底层** | 剩下的按权重+新近度,外加轮换名额 | 保底 | 几个刻意的取舍: - **固定层不设百分比上限**。最初的实现给了它 45% 的预算,结果会出现最难受的那种失败:面板上明明有 📌,注入里却没有,用户完全不知道为什么。现在固定层先占位、不受比例限制,真装不下时宁可让别的层少装,并把没塞进去的条数记进 `pinnedDropped`。 - **话题层的查询词只取用户发言**,不取 agent 自己的回复。把 agent 回复算进来,BM25 会把「她上次提过的记忆」判成相关 —— 那是自我强化,不是在想事情。取最近 4 条用户消息、每条截 200 字。 - **拿不到会话内容时话题层自动空着**,名额让给兜底层。`AssembleContext` 的 `.d.ts` 里根本没有 `agent` 字段,运行时才有 —— 这是「能用但不保证」的能力,所以整段包在 try/catch 里,拿不到就返回空串,绝不让整体注入因为拿不到会话而变差。 - **第一次运行自动钉一批**(权重 + 称呼/人设/约定类关键词 + 是否承诺,门槛 score ≥ 2,最多 5 条),只用 `store.meta.pinnedSeededAt` 做一次。用 meta 而不是「没有任何条目被钉就再钉」的理由:后者会让用户取消全部钉住之后,下次启动又被系统钉回来 —— 那等于用户的话不算数。 - 还有一条**反面名单**(`NOT_PIN_HINTS = ['生日','纪念日','周年','节日','忌日']`):命中就不进固定层,哪怕它同时写了「答应」。起因是真实数据——「10月18日……也是阿风的生日……小鲸鱼答应记住这天、每年一起过」因为「答应」得了 1 分、权重 5 再加 1 分,正好过门槛被钉住;而用户原话里举的反例就是「我的生日」。正向匹配挡不住这种条目:它确实「答应」过,但一年只用一次。这类条目交给话题层(聊到生日时检索出来)和近期层(临近那天被提到时自然进上下文)就够了。 - `PIN_HINTS` 里的「主人」「名字」后来删掉了:这两个词在引号、视频标题、别人的话里到处都是。真实库里一条「去抖音看《蓝色大肥鱼:主人!起床啦!》」的待办就因为标题带「主人」被钉住 —— 一次性待办被永久供在固定层,是这类启发式最典型的翻车方式。 **代价**:四层意味着「为什么这条在、那条不在」不能再用一句「按分数排的」解释。所以面板必须显示分层明细(每层进了几条),否则用户面对一个黑箱。 --- ## 四、注入和检索用同一个打分函数 **备选方案**:面板显示用一套算法(好看、好解释),注入用另一套(快、简单)。 **没选它,因为**:这是记忆系统最不能出的问题 —— **「面板上显示的」和「她真读到的」变成两套真相**。 用户看着面板说「她记得我考研的事」,而实际注入里没有这条,因为两套算法打分不同。用户永远发现不了,直到某天她真的忘了,用户才觉得「这系统不靠谱」,但那时已经说不清是哪一环出的问题。 所以 `lib/index.js` 里注入和 `GET /snapshot` 走的是同一个 `selectLongMemories`,面板上「她会想起什么」这一页直接显示后端算出的**注入块原文**,不是重新渲染一遍。 这条约束也决定了测试怎么写:`test/host.test.mjs` 里有断言直接比对注入块和 snapshot 的 `injected.block` 是否逐字相同。它们不同就说明有人偷偷加了第二套逻辑。 --- ## 五、会淡忘,但淡忘不是删除 **备选方案**:记忆永久保留,靠检索排序自然淘汰。 **没选它,因为**:库里只增不减,检索质量会随条目数单调下降。第 500 条记忆和第 5 条记忆在排序上竞争,结果是「最近提过的」永远排在前面,「重要但很久没提的」永远沉底 —— 而那恰恰是最该被记住的东西。 所以有衰减:强度按 `weight * 0.5^(ageDays / halfLife)` 下降,低于阈值自动归档。 但**归档不是删除**:条目还在库里,仍可被检索、仍可复活。这是刻意的 —— 衰减算法一定会有算错的时候(半衰期配错了、权重标低了),如果衰减直接删数据,用户唯一的补救方式就是重新告诉它一遍,而用户根本不知道自己丢了什么。 **为什么不做后台定时器**:一个插件在后台悄悄改用户的记忆文件、用户既看不见也没法阻止,这种事不该出现在别人的记忆里。所以整理只在两个明确时刻跑:启动时、每次写入后。同一分钟内不重复跑,避免连续写入时反复整理。 **为什么半衰期按类型分开,而不是一个数字**:一个数字看起来更简单,但它强迫所有记忆用同一种速度淡去。后果很具体 —— 「周五给你讲睡前故事」这种约定和「我在准备考研」这种事实,被同一把尺子量,前者会一直挂在那里提醒一件早就过去的事。约定有期限,事实没有,它们的淡去速度本来就不该一样。所以默认值是 `{fact: 365, pref: 180, promise: 30, event: 14}`,同时仍接受单个数字(想统一速度的人可以填 90)。 这条曾经真的错了:插件早期用 `Number(setting(config, 'decayHalfLifeDays', 365))` 把内核的分类型表压成了一个数字,26 条真实记忆全部落在 2000 天左右才淡忘 —— 「会淡忘」这条承诺在默认配置下几乎不会发生。`test/host.test.mjs` 的 6c/6d 两组断言现在盯着它:同一时刻写下的约定必须比事实淡得快一倍以上。 **为什么归档阈值和「预计淡忘」用同一个数**:面板上算「还有 N 天淡忘」和整理时判断「该不该沉下去」,必须是同一把尺子。否则面板说「还有 70 天」而实际永远不会沉,用户看着一个永远不变的数字,以为系统在正常工作 —— 这类「两套真相」比功能缺失更难发现。 **为什么一次最多沉 3 条**:衰减本来就慢。某天突然沉掉几十条,几乎一定是半衰期被调错了 —— 而等用户发现时,已经「失忆」了。限速让错误变慢,给人留出反应时间。 ### 为什么把「还剩几天淡忘」写在每一条记忆上 强度是个抽象小数(0.87)。用户看着它不知道这意味着什么 —— 是「还好」还是「快没了」?换成「约 24 天后淡忘」才是一句能据以行动的话。所以每条生效中的记忆都会带一个剩余天数,按紧迫度分三档上色:≤7 天红、8~30 天橙、更久中性灰。**颜色只加在那一个 span 上,不动整张卡片** —— 整卡标红会让「快忘了」看起来像「出错了」,而淡忘本来是这个系统的正常工作。 **数字绝不重算**:`falloutInDays` 是 snapshot 里由内核 `longMemoryFalloutDays()` 算好的成品值,前端直接用。前端要是自己再按 `weight * 0.5^(ageDays / halfLife)` 推一遍,只要时间戳差几毫秒、或者以后有人调了阈值,两边就会分叉,用户看到的是「它明明说还有 5 天,怎么就没了」。这正是第五节那条「两套真相比功能缺失更难发现」的另一个入口,所以 `test/client.test.mjs` 里有一条静态断言直接**禁止**客户端出现 `Math.pow(0.5` 和 `DEFAULT_HALF_LIFE_DAYS`。 **只有 active 才有倒计时**。已归档 / 已结案 / 已取代的条目在 snapshot 里**同样带着 `falloutInDays`**(实测 34 条里有 2 条 superseded 带着 23.3 / 30.5),但它们不会因为到期而淡出 —— 给它们挂一个倒计时是纯误导。所以 `falloutOf()` 第一行就判 `item.status !== 'active'` 返回 null,测试也盯着这一行(反向验证里「去掉 active 判断」必须立刻报错)。 **0 天不说「约 0 天后淡忘」**:0 意味着「按当前强度已经跌破阈值」,这时候说「约 0 天后淡忘」像句废话,直接说「已到淡忘线」。 ### 为什么单列一个「快过期」,而且故意不接筛选 因为「还剩几天」写在一张卡片里,只在你想起来翻它的时候才有用。而记忆淡出是一件**有时间窗**的事:30 天内会淡忘的条目,现在按续命还来得及,过了就沉底了(归档不是删除,但也不会再自然出现)。所以单开一屏,按最急的排前面,把该管的聚在一起。它排在「记忆列表」后面第二个 —— 它是列表的一个预筛,放到最后面反而要点四次标签才到。 **不接搜索框和状态筛选是刻意的**:这一屏的意义是一份固定口径的清单(30 天内会淡忘的生效记忆),一加筛选就不再是那个清单,而变成一个「我没找对条件」的搜索页。想搜的人去「记忆列表」。 **它大部分时间会是空的,那是对的**。半衰期按类型分开之后(事件 14 / 约定 30 / 偏好 180 / 事实 365,跌破 0.6 才淡出),真实库里 30 天内会淡忘的通常只有 0~1 条。所以空态不写「暂无数据」,而是把**最近的一条还差多少天**讲出来 —— 有具体数字,人才知道这个视图是活的,只是暂时没什么可提醒的。**也不要为了显得有内容就把阈值放宽到 60 天**:那等于把「体检正常」改成「人人都有病」,用户反而学不会什么时候该按续命。数量也一律按实际算(`${soon.length}`),不写死。 ### 钉住 ≠ 不会淡忘(这条最初写错了,实测纠正) 早期 UI 给钉住的条目显示「📌 不会淡忘」。这是**错的**,而且错得很危险 —— 它承诺了系统做不到的事。 实测:`pinLongMemory()` 只写 `pinned` / `pinnedAt` / `pinnedAuto` 三个字段(外加 `updatedAt`),**完全没碰 `weight` 和 `lastUsedAt`**。所以钉住前后 `falloutInDays` 一模一样;一条钉住但已衰减的条目照样会被 `organizeLongMemories()` 归成 `archived`(`archiveReason: 'decay'`)。 钉住真正买到的是**每轮必进注入**:`selectLayeredMemories()` 的固定层直接 `live.filter((m) => m.pinned === true)`,不看分数、不参与轮换、不设百分比上限(见第三节)。它解决的是「重要但打分不高的条目永远排不进前 12 名」,不是「这条永远不会沉」。 所以现在的做法是:钉住的条目**照样显示剩余天数**(藏掉倒计时等于骗人),徽章文案也只写它真正保证的东西 ——「📌 固定层(每轮必进)」/「📌 自动钉住(每轮必进,可取消)」。想让它留久一点,该按的是**续命**:`lastUsedAt` 归零、`weight +0.5`(上限 5),实测一条已到淡忘线的记忆续命后回到 35.6 天。 --- ## 六、自带存储,不寄生别的服务 **备选方案**:复用 DSH 的某个已有存储,或者要求用户先装另一个服务。 **没选它,因为**:开源插件的第一条要求是**装上就能用**。如果 `dsh-memory-lens` 依赖 `qq-bridge` 或者某个数据库,那么每一个想试它的人都得先装两样东西 —— 大多数人在第一步就放弃了。 所以存储是 `$DSH_HOME/memory-lens/memory.json`,一个明文 JSON 文件。跟着 DSH 自己的数据根目录走,换机器、换盘都不会丢。 明文是刻意的:可读、可改、可同步(丢进坚果云/OneDrive 就是多机同步)、可备份、可 diff。加密会让这一切都做不了,而「密钥、证件号」这类东西在写入前已经被 `looksSensitive()` 拦掉了 —— 拦的是模式匹配,不是保险箱,这个边界在 README 里写清楚了,不假装它是加密存储。 --- ## 七、星图用手写投影,不用 three.js **备选方案**:`import * as THREE from 'three'`,十几行就能出一个旋转的立体星图。 **没选它,因为**:多一个外部依赖就多一个「打不开」的理由。这个面板要在离线环境、受限网络、各种版本的 DSH 里都能跑起来 —— 一个 600KB 的依赖引入的失败模式(版本冲突、peer 依赖缺失、打包路径不对)远多于它省下的那点代码。 71 个点的透视投影自己算只要几十行: ```js const zcRaw = z2 + P.camDist; const zc = Math.max(60, zcRaw); // 地板 60(原来是 140) const k = P.fov / zc; return { x: P.w / 2 + x1 * k, y: P.h / 2 + y1 * k, k, zc, behind: zcRaw < 24 }; ``` `k` 是透视缩放系数,节点半径和线宽都乘它 —— 自动 LOD,远处的点自然变小变淡。够了。 代价是要自己处理雾化、光晕缓存、拖拽的坐标反旋。这些加起来大概 300 行,但都是可读的、可调试的、出错时能定位到具体一行的那种代码。 ### 为什么原来只能放大 2.2 倍,现在能到约 8 倍 放大倍率由**两个**数决定,它们负责的是不同的东西: 1. **滚轮能推到的最近距离 `P.dist` 下限**(`onWheel`)。原来下限 0.45、上限 2.6 —— 相对默认视角(`dist = 1`)只能放大约 1/0.45 ≈ 2.2 倍,所以滚轮滚到底也只能看到那么大。现在下限 0.12、上限 3.2:1/0.12 ≈ 8.3,约 8 倍;上限顺带放到 3.2,「拉远看全貌」也不至于不够用。**这一条决定了「整体能推多近」**。 2. **透视投影的 `zc` 地板**。`k = fov / zc`,而 `zc = max(地板, z2 + camDist)`。地板原本是 140 —— 相机推到很近时,那些离相机**最近**的点 `z2 + camDist` 会小于 140,于是被地板托住,`k` 到 140 就封顶了。**这一条决定的是「最近的那批点能放到多大」**,不是整体倍率。 按 `draw()` 里同一套投影公式算出来的对比(`fov = 640`、`fit = 600`、332 个点、球壳分布;这是公式推演,不是真机渲染数据): | 配置 | 中位 k(相对 dist=1) | 最大 k | 跑到相机背后的点 | | --- | --- | --- | --- | | 旧:dist 0.45 + 地板 140 | 2.2× | 1.78 | 0 / 332 | | dist 0.45 + 地板 60 | 2.2× | 1.78 | 0 / 332 | | dist 0.12 + 地板 140 | 7.1× | 4.57 | 50 / 332 | | 新:dist 0.12 + 地板 60 | 7.1× | **10.67** | 50 / 332 | 两行「dist 0.45」说明:**只在 dist 能推到的范围内,地板根本没被碰到**,降地板一点用都没有 —— 地板只有在相机真的推近之后才开始起作用。而第三、四行说明:`dist` 放宽之后整体倍率就已经上去了(7.1×),降地板换来的是**最近那批点还能再近一档**(最大 k 4.57 → 10.67,2.3×)。所以两处都改,但作用不同:`dist` 管「能推多近」,地板管「推近之后最近的点会不会被截顶」。用户抱怨的「凑近看不清单个光点」,正是后一条。 ### 为什么必须裁掉跑到相机背后的节点与边 推到很近时,相机会进到云团内部 —— 有些节点的 `zcRaw = z2 + camDist` 会变成**负数或极小值**,也就是这个点已经跑到相机背后了。而 `k = fov / zc` 在 `zc` 趋近 0 时会爆掉,一个这样的点就能以极大半径糊满整屏;连到它的边也会被拉成横穿画面的长条。画面不是「变模糊」,是**直接糊掉**,而且用户会以为是插件坏了。 这不是理论担忧:上表最后两行里 332 个点有 **50 个**(15%)跑到相机背后 —— 而这批点是在 `dist` 放宽到 0.12 之后才出现的(旧的 0.45 下是 0 个)。换句话说,「能推到很近」这个能力本身**必然**带来「一堆点跑到背后」,两个改动是绑在一起的。 所以 `project()` 返回一个 `behind: zcRaw < 24` 标记,`draw()` 里**节点和边都按它裁掉**:节点 `.filter((it) => !it.p.behind)`,边 `.filter((e) => e.pa && e.pb && !e.pa.behind && !e.pb.behind)`(边的两个端点都要判,只判一个仍会留下半条飞线)。裁掉是刻意的取舍 —— 背后那些点本来就不该出现在这张投影里,与其画成糊块,不如不画。 这三条(`dist` 的 0.12/3.2、`zc` 地板 60、`behind` 标记与两处过滤)都在 `test/client.test.mjs` 里有静态断言守着。它们退化时不报错、不抛异常,只是「滚到底还是看不清」或「一推近就糊屏」—— 只有静态断言能拦住。 --- ## 八、测试跑真实代码,不复制逻辑 **备选方案**:把核心逻辑抽出来单测,UI 部分靠人工点。 **没选它,因为**:这类插件的失败模式大多是**「装上了,但什么都没发生」** —— 没有异常、没有日志,只是那个页面不出现,或者那个工具没注册上。 复制一份逻辑来测,测的是那份复制品。而真实失败发生在「注册没生效」「inject 依赖写错」「slot 名字拼错」这些装配环节 —— 恰恰是复制品里不存在的东西。 所以三个测试都用最小桩跑真实文件: - `test/host.test.mjs` —— 假 `ctx`,真跑 `apply()`,断言 section 注册了几个、工具注册了几个、路由注册了几个 - `test/client.test.mjs` —— 假 `__ModuleLoader__` + 假 React,真跑 `lib/client.js`,断言 `load()` 调用一次、slot 名和 order 对、loader entry id 等于 `NAMESPACE` - `test/tools.test.mjs` —— 真 `defineTool` 桩,62 项断言覆盖三个工具的行为边界 这些测试抓到过的真缺陷,包括: - `config.injectMaxChars` 拿到的是 volatile 访问器对象而不是裸值 → `Number()` 得 `NaN` → **预算、半衰期、上限全部静默失效** - `activeList(store.load(), 'shared')` 传了整个 store 对象 → `Array.isArray` 失败后静默返回 `[]` → **注入永远是空字符串** - `export const inject` 里写了 `webServer` → headless profile 里整个插件卡在 pending,因为那个服务不存在 - `MemoryStore.load()` 的 mtime 容差 1ms → 别人在上次保存后 1ms 内写入被当成「没变化」→ **文件里已有、注入里没有**;跑得越快越容易撞上,所以 Linux CI 稳定失败、本机随机通过 - `frame()` 无条件续帧 → 力学收敛后仍 60 次/秒重画几百个点 → **静置吃掉 12%~36% 主线程**(最后一条是加了个静态断言才守住的,见下) 最后那条不一样:它不渲染就不跑帧,单元测试天然抓不到。所以 `test/client.test.mjs` 里加了**静态断言** —— 盯住「`frame()` 末尾的续帧必须被 `needsFrame()` 包住、不许有裸的 `requestAnimationFrame`、`needsFrame()` 的五个条件一个都不能少、30fps 限帧那一行必须存在、唤醒点至少 7 处」。 放大倍率那三条同理,而且更隐蔽:滚轮下限、`zc` 地板、`behind` 过滤写错了都不报错、不抛异常 —— 只是「滚到底还是看不清」或「一推近就糊屏」,用户会当成插件坏了。所以它们也在 `test/client.test.mjs` 里有静态断言守着:`dist` 下限 ≤0.12 且上限 ≥3.2、`zc` 地板必须是 60(不是 140)、`project()` 必须返回 `behind` 标记、`draw()` 对节点和边都做了 `behind` 过滤、提示条必须写明「可推到很近」。每条都做过**反向验证**:把值改回旧值,测试必须立刻红 —— 断言守不住的值等于没断言。比数值时会先剥掉注释,因为解释「从 0.45 放宽到 0.12」的说明文字本身含有旧值,不剥会把断言顶死。 「剩余天数 / 快过期」那一组同理,而且退化的样子更软:数字算错了没人报错,只是显示的和归档对不上;阈值放宽了也没人报错,只是那一屏永远有内容。所以断言盯的是**口径**而不是像素:`FALLOUT_NEAR = 7` / `FALLOUT_SOON = 30` 精确匹配、客户端不得出现 `Math.pow(0.5` 或 `DEFAULT_HALF_LIFE_DAYS`(禁止重算衰减)、`falloutOf()` 必须判 active、三档色必须有、徽章文案必须写清「每轮必进」且全局不得出现「不会淡忘」、`VIEWS` 里 `soon` 必须排在 `list` 之后、`soon` 的过滤必须含 `status === 'active'` 且用 `<` 不是 `<=`、排序必须升序、`soon` 分支里不得出现搜索框与状态筛选、空态不得只写「暂无数据」、数量必须来自 `soon.length`。 这一组做反向验证时踩到过三个假阳性,值得记下来:① 截函数体的小工具 `bodyOf()` 原来把结束符写死成 8 空格缩进的 `};`,而 `falloutOf` 是 4 空格缩进的 `function` 声明,结果一路截到下一个 8 空格闭合处、把整个 `ItemCard` 吞了进来 —— 于是「去掉 active 判断」这个 case 假通过;改成按声明行自身缩进算收尾才对。② 断言「徽章文案」时切片起点必须放在 `className: 'memlens-pin-badge'` **之后**,否则 className 字面量本身被当成一条文案。③ 「全局搜 `soon.length`」或「全局搜『每轮必进』」都会被另一处合法代码满足,必须精确到那一句模板串、或者逐条字符串字面量判。**反向验证本身也会假通过** —— 一个抓不住的 case 说明断言有洞,不说明代码没问题。 这类「行为正确性只能靠真实渲染验证、但退化方式极其隐蔽」的代码,静态断言比没有强得多。 三个(前三个)都属于「没有异常、只是不工作」那一类。人工点是点不出来的。 --- ## 九、已知的取舍,不是没做完 - **不做自动抽取** —— 见第一节,是设计选择 - **单桶** —— core 支持多桶,但一个 agent 一个记忆库就够了;多桶只会让「她到底记得什么」变模糊 - **不加密** —— 见第五节 - **中文优化** —— 分词按字 + 二元组,英文检索能用但不如中文准。这是目标用户决定的:这套东西是为中文对话场景做的 - **没有导出按钮** —— 数据本来就是一个明文 JSON 文件,`cp` 一下就是导出。加个按钮只是把 `cp` 包装一层 - **话题层靠「最近几条用户发言」当查询词** —— 更准的做法是让模型自己总结这次要聊什么再检索,但那要给注入加一次额外的模型调用,每轮都付。现在的做法在「多轮闲聊」里够用,在「一次只发两个字就切话题」时可能检不出东西 —— 那时兜底层会接上,不会空 - **固定层只认「钉住」这个显式标记** —— 不做「自动判断什么该一直记着」。首次运行的那批自动钉是**一次性**的引导,之后完全由用户(或 agent 明确要求)决定,否则用户取消过的条目会被系统反复钉回来