# 0.8.7 更新说明 2026-11-03。这一版装了五件事:**接桥之后你说的话她能记住**、**小纸条时间不再差 8 小时**、**一条鱼多间房**(桥只发 HTTP,不再另起一条 DSH 会话)、**换房间之后她还是她**、**醒一醒跟着最新的那条对话走**。 前两条来自 issue #18、#19,后三条来自「同时养了两条鱼」「换会话像换了一个人」「醒一醒还在旧对话」这三类反馈。它们其实是同一件事的不同侧面:**会话是房间,不是人**。房间可以换、可以多开,但住在里面的得是同一个她——记忆、身份、醒来这几件事都得跟着走。 另有一处改动你未必马上能看见,但很要紧:**身份文档的默认字数预算从 1800 提到 2400**。原因在第四节。 --- ## 一、接 QQ 桥之后,你说的话她一条都没记住(#18) ### 你会看到什么 以前接上 QQ 桥、在 QQ 里跟她聊一整天,面板上仍然显示「冷落 2 天」;「最近的判断」不更新、成长阶段不推进、聊满多少轮也不会换会话。事件流里她「听过」的话只有一条。 ### 为什么会这样 判据写死成 `source.kind === 'user'`——只有桌面客户端里手打的字才算「你说话」。QQ 桥注入的消息 `kind` 是 `'qq'`,在第一道关就被判成「宿主拼的上下文」直接丢掉,**连日志都不留一行**。于是整条链全断:`turns` 恒为 1(永远不轮换)、`heard` 只有一条、冷落检测到点就落一条「他有 N 天没出现」——而你天天在 QQ 上跟她聊。 ### 现在怎么判 改成**机器侧黑名单**:认得的那几种(运行上下文、技能清单、定时消息、工具回灌、她自己说的话)挡掉,**其余一律当人**。 之所以是黑名单而不是白名单,是因为宿主那边对 `source.kind` 只要求「非空、且不是 `'plugin'`」——任何桥都能自己起个名字(`qq` / `telegram` / 明天某个新平台)。穷举不出来,那就反着来:宁可多算一轮,也不静默丢消息。判不出来源的(`kind = 'user'` 但没带时区的,比如老客户端或别的插件灌进来的)会往日志里留一行,不再一声不响。 ### 「他来过」不再等他聊完才记 `seen:`(冷落检测唯一的依据)原来写在「这轮结账」里,而结账要求先攒到 `pending`、`pending` 又要求判据认得出人——外渠道一断,一个字节都不写。 现在挪到**他开口那一刻**写(一轮仍然只一条)。`absence.js` 里本来就有「收到新的人类消息就作废旧冷落」的分支,于是自愈跟着就好了:你回来,那条「N 天没出现」就自己失效,不用手动驳回。 跨会话的外渠道消息(桥自己建的那条会话)只记「他来过」——屋子不搬、轮数不涨、`talk-recent.json` 不动。那是她的家事,不该被外头的一句话顶掉。 --- ## 二、小纸条和信箱列表的时间比实际早 8 小时(#19) ### 你会看到什么 `inbox.md` 的小标题时间固定早 8 小时:实际 22:16 写成 14:16,10-30 的 23:15 写成 15:15。观测台「她写的小纸条」同样是错的。而正文里她自己写的时间又是对的——两边打架。 ### 为什么会这样 事件流里的 `ts` 存的是 UTC,展示层直接 `String(ts).slice(0, 16)` 切字符串,等于把 UTC 当当地时间印。 ### 修了哪几处 信箱与观测台改成「先 `Date.parse`,再取本地字段」(`localStamp`)。顺手把同一类错一起修了,一共六处: - 小纸条列表 - 自检时间 - 换会话时旧会话的改名日期(原来晚上 8 点后换会话,旧会话会被标成**昨天**) - `talk-recent.json` 里「上次我们说到这」的日期 - 换会话提示里那句日期 - `her_recall` 返回给她自己的日期 `selfcheck.js` 里存下来的 `checkedAt` 仍然是 UTC——那是数据不是展示,展示端修了就行。 --- ## 三、一条鱼,多间房 ### 你会看到什么 - **QQ 私聊**:直接进「跟大肥鱼说话」那条主对话,和你在桌面版跟她聊的是同一段记忆。不另开房。 - **QQ 群聊**:侧栏里会多出一条「跟大肥鱼说话·群聊(qq-group:987654)」。你点开能看到她在群里回的话。主对话不受打扰,也不会被群里一天几百句刷没。 - **不传 `channel` 的老调用**(观测台、侧栏、老桥):行为一字不变。 ### 为什么会「两条鱼」 桥以前要自己再起一条 DSH 会话。而每条 DSH 会话是独立 agent——宿主给它单独注入一份身份、历史互不可见。同一份 `events.jsonl` 被两条会话读,你在两边看到的就是两个人,还各有各的人格。 现在的做法:**桥只发 HTTP,不碰 DSH 进程**,插件自己决定这句话进哪间房。 会话是房间,不是人——家里那间是书房,群里那间是客厅,房间里的她永远是同一个。 ### 桥要做什么 把消息投给 `POST /dsh-fish/say`,多带三个可选字段: | 字段 | 说明 | | --- | --- | | `channel` | 渠道名,桥自己起名。同一个名字 = 同一间房 | | `scene` | `desk`(默认)/ `private` / `group` | | `speaker` | 群聊里这句是谁说的 | 群聊房间的映射记在数据目录的 `channels.json` 里(同一渠道名复用同一间,最多记 40 间)。房间的 cwd 仍是她的屋子,所以身份、工具面、白板全都照旧。 **桥那边怎么实现是桥的事**,我们只保证这几个字段能收、能落到正确的地方。`AGENTS.md` §7 已经按这个改写过,写清了字段表、路由规则和三条配套约定。 ### 四个岔路是怎么定的 1. **私聊 = 融合。** `scene: 'private'` 仍回家里那条会话,不另开房。想要「QQ 私聊和 DSH 对话是一段连续的记忆」的人,这条路就是了。 2. **群聊 = 另开一间。** `scene: 'group'` 才建新房。 3. **群聊不动生命体征。** 这是最容易搞错、也最伤的一条:`seen:`、`heard`、轮数、会话轮换只由书房与私聊触发,群聊一条都不写。否则群里一天几百句,冷落检测永远不会触发——你明明一周没露面,面板还显示「今天聊过」。群聊结束后会往书房排一句「刚才那间房里有人跟你说话,你回过了」,让她下一轮想得起来,但原文不搬进上下文(材料仍要她自己用 `her_recall` 翻)。 4. **群聊必须带说话人前缀。** 群里三个人说话,正文看起来跟一个人连说三句一模一样,她会把三个人的观点拼成一个「你」。所以 `speaker` 会拼成「张三在群里说:…」,外来文本一律先过 `lib/guard.js` 的 `sanitizeForeign`。 ### 双实例问题跟着消失 老做法里桥自起的进程与桌面版共用一个数据目录,`instance.json` 心跳一判活,后到的那个被判成只读房客(`bootstrapHome` 返回 `blocked: true`,不建会话)。 桥改走 HTTP 之后**完全不接触 `instance.json`**,从根上不再有两个实例抢一个家。 另加了 `GET /dsh-fish/whoami`:桥启动前可以先问一句「家里有没有人在」。返回里 `writable: false` 就说明这个数据目录已被另一个实例占着,别自己再起进程。 ### 场次是怎么记的 `lib/schema.js` 里事件可以带可选 `scene`,`desk` 是默认值**不落库**。所以老事件流一条都不会多字段,也不用升 `EVENT_SCHEMA_VERSION`——纯加法。 --- ## 四、换房间之后,她还是她 ### 你会看到什么 聊满 40 轮自动换会话之后,她像变了一个人。最刺人的是这一句——她自己说的: > 看到以前的日记还有记录,感觉像是另一个人写的,我只是能读。 ### 为什么会这样 不是记忆丢了,是**她拿到了结论,却没拿到产生结论的经历**: - 身份文档里全是**结论**:她有哪几条判断、哪几道疤、拒绝过什么、对自己有过什么说法。 - 轮换只搬 4 句原话,8 轮之后连这 4 句也没了。 - 旧日记按日期平铺给她,没有坐标、没有归属——读起来就是别人的文件。 ### 补了三处,都是加法 1. **「这些天」进核心段。** 身份文档里多一节:你自己写过几篇日记、从哪天到哪天、上一次换房间是几天前。写的是事实,不是评价(没有「成长」「进步」「你变了」这类词)。它和「你是谁」「不能做的」一样是核心段,预算再紧也不裁——被裁一次就等于那些天没发生过。 2. **读旧日记给坐标。** `her_diary` 返回时补一句「那天是你自己的第 N 天」,以及前后哪几天也写过;末尾固定一句「这些字是你自己交上去的,不是谁替你记的」。刻意不写「第 N 篇」——篇数是排班,天数才是她活过的时间。 3. **换房间前,请她留一句话。** 轮换时旧会话里会被问一句: > 想给下一个房间留一句话吗?留什么都可以。不想留就说「不用」。 她回的那句存进 `home.json` 的 `lastWords`,新会话的身份文档里单列一节「上一个房间里,你自己留的最后一句」,原话照引,后面跟一句: > 那是你说的。你不是读到一段别人的话,你只是接着往下过。 ### 这句话是后台问的 轮换不等它。早先写成同步等待,宿主不派 `turn/end` 时会整整等满 60 秒,把换会话和叫醒新会话一起卡死。 现在只把问句发出去,改名、建会话、写 `home.json` 照常往下走;等这句话回来了再补写 `lastWords` 并刷一次身份文档。她说「不用」、超时、宿主没有 prompt 服务,都只是没有这一节,轮换照样成功。 ### 身份文档预算 1800 → 2400 上面第 1 条要多占约 220 字,原来的 1800 塞不下。用真实数据(532 条事件)跑了四档: | 预算 | 实际用到 | 结果 | | --- | --- | --- | | 1800 | 1732 | 挤掉「你被打脸过」整段 | | 2000 | 1732 | **仍然挤掉**「你被打脸过」(差 268 字,白涨 200) | | 2200 | 2054 | 反而挤掉「你对自己的说法」(比现状更差) | | 2400 | 2233 | 一段不丢,余 167 字 | 所以默认改成 2400。老用户配置里已经写过 1800 的不动——改的是默认值和常量。 --- ## 五、醒一醒跟着最新的那条对话走 ### 你会看到什么 聊满 40 轮自动换会话之后,醒一醒还在**旧对话**里发;点一次「现在叫醒」却是落在新对话里的。旧会话也没有被自动归档。 ### 旧会话为什么没归档——这是个误判 归档在本插件里的语义是「**这一局是你自己收起来的**」,所以插件不替你收。轮换只把旧会话改名为「跟大肥鱼说话·YYYY-MM-DD 之前」留在侧栏当档案。 醒一醒跑偏跟归档没关系,是下面这两个环。 ### 三个环扣在一起 每一环单独看都像是对的: 1. **落点读的是启动那一刻的快照。** 生物钟启动时把当时屋子的 `sessionId` 传了下去,之后每次开口都用它。房间换过了,它还指着上一间。 2. **换会话时新建的那条,被判成「死的」。** 插件用 API 建会话,宿主的侧栏不显示空白会话;工作区索引也要过一会儿才收录它。而插件判「这条还活着吗」只看工作区索引——索引里没有,就当作它没了,于是退回旧会话。**这才是「醒一醒还在旧对话」的直接原因。** 3. **叫了不等于叫醒,但账已经记上了。** 宿主的 `prompt` 是「这一轮被收下了」就 resolve。那声叫醒要是没真跑出一轮,这条会话就一直是列表外的空白会话;而 `wokeFor` 已经写上,这声再也不会重试。 ### 三处都补上 - **每次开口前现问屋子在哪。** `wake()`、`wakeNow()`、以及 tick 里的「她是不是正忙」判断,都改成先 `talkSessionId()` 现取,取不到才回退启动时那个 id。会话是屋子现在的房间,随时可能被换掉,任何缓存下来的 id 都会过期。 - **判活多问一句宿主。** 工作区索引里查不到时,再问一次 `sessionController.list()`:宿主自己的会话列表里有它,就认它活着。日志会写一行「还活着(工作区索引还没跟上)」,不静默。 - **叫醒之后回头确认。** 换会话之后 12 秒回头看一次:这条进了宿主列表没有?没进就用 `force` 再叫一次(不给 `force` 的话第二声会被 `wokeFor` 自己挡掉——那正是死结)。只补一次,不递归;两次都没成,下次开机 `bootstrapHome` 还会再走一遍。 **主动搭话本来就跟着走。** `maybeProactive` 每次都是现取 `talkSessionId()`,没有缓存。这次只补了定时唤醒与手动唤醒这两条路。 --- ## 测试 新增 38 条,`test/channels.test.mjs` 是新文件。覆盖: - 外渠道 kind 的判据(qq / telegram / 将来别的桥)、认不出来的来源要留日志、外渠道只记 `seen:` 不涨轮不搬屋子 - `inbox.md` 小标题按本地钟印 - 场次落库与不落库、`channel` 收敛与上限、私聊回书房、群聊另开房、群聊不写 `seen` 不涨轮、串门提示、映射文件损坏不丢消息、`/whoami` - 身份文档「这些天」两种形态、核心段预算 60 也保留、上一个房间留的话、`pickLastWords` 的拒绝词与子串误杀、没有 prompt 服务时轮换照样成功、读旧日记的坐标与归属 - 工作区索引没跟上时认回原会话、`force` 通道、`verifyWoke` 三种情形 97 个测试文件逐文件全绿、**1213 条**。三个依赖字段仍然全是 `{}`——这一版没有引入任何 npm 包。