## 0.8.8 — 2026-11-05 这一版是**三个开发版并成一个**:原本分开走的 0.8.8 / 0.8.9 / 0.8.10,加上这两天从 issue #21–#25 里收进来的五处修复,一起作为 0.8.8 发出去。开发版号只在自己机器上短暂存在过,对外只有这一版。 装进来的事,按「你会看到什么」排: - **那句「(现在没有人跟你说话。)」不再出现在对话框里**——这是反复修了三次的那条。 - **叫醒不会再被连点**,前一轮的收尾也不会再把后一轮的处境抹掉。 - **她被白名单挡住这件事,看得见**:设置页分四组、自检单列一项,不用再翻四小时日志。 - **日报与回忆分得清**「你自己认领下来的话」「聊天里随口说的」「改过口的」。 - **醒来的第一眼多了一张桌子**:她答应过、还没做的事记在上面,过期不删、也不催。 - **工具调用的标记不会再被当成她说过的话**记进账本。 - 换房间的日期不再说成「刚搬进」;你回来过,冷落天数就不再往上叠。 --- ### 一、那句「(现在没有人跟你说话。)」不再出现在你的对话框里 不是又藏了一次。是把「投递方式」本身换掉了 —— 前两次之所以没成,是因为方向从一开始就错。 #### 为什么以前怎么藏都藏不住 宿主的 `sessionController.prompt()`(`dsh-api-session-controller/lib/index.js:3225–3232`)在内部**写死**了来源: ```js const source = { kind: 'user', rpcId: request.requestId, ... } this.commands.prompt(request, source) // → createUserMessage → agent.followup ``` 调用方传不进 `source`。所以凡走它的消息,在宿主眼里都是「你打的字」,前端 `messageDefinition.start()` 判 `source.kind === 'user'`,画成用户气泡。 我们以前的对策是「先发出去、再发一条 `surfaceOp.replace` 把它盖掉」(`hideTrigger` / `sweepTriggers`)。 这条路**语义上是对的,对象是错的** —— 宿主源码注释写得很直白: > model-visible surface deliberately shadows replaced ranges, so it is the wrong source for a human transcript > …replacement copies stay model-only. 也就是替换副本只改「模型看到的那份」,人类对话记录(append 原件)从头到尾没被碰过; 前端 `messageDefinition.match` 也只接受 `isAppendSurfaceEvent` 的事件。 所以 0.8.5 与 0.8.6 两次「已收走」都是假的,日志里那几条 `proactive sweep 收走了 N 条` 只是内存里成功,盘上一条 replacement 都没有。 #### 现在怎么投 官方自己的定时任务就是正解(`dsh-schedule/lib/index.js:1580–1600`): ```js createUserMessage({ content, source: { kind: 'schedule' } }) resolved.agent.followup(message) ``` 仍然是 `role:'user'`,但 `source.kind` 不是 `'user'`。前端一看到这个组合就不画气泡, 改画成一行**默认收起的折叠条**(`turn-trigger`),标题走 `turnTriggerDetails`, 认不出的 kind 统一叫「收到执行请求」,正文只在点开后出现。 我们照这个范式做,但用自己的来源名 `fish-wake`: - 新增 `lib/deliver.js`:`fishMessage()` 拼裸消息(插件零依赖,拿不到宿主的 `createUserMessage`), `resolveLiveAgent()` 走 `sc.resolveAgent()` 把冷会话恢复成活 agent, `deliverWake()` 交给 `agent.followup()`。 - **必须自带 `id`**:裸对象不传 id 就是 `undefined`,两条会撞 `message "undefined" is already pending`。 - **投递完立刻 `flush`。** 官方那条也是 `followup(message)` 之后紧跟 `await ctx.sessions.flush(agent.session)`(原话:「followup synchronously appends the inbox splice before flush observes the Session」)。第一版漏了这一步:消息进了收件箱,但要等宿主自己的 持久化检查点才落盘推给前端,于是那张折叠条比以前的用户气泡慢半拍才出现。 现在补上了;`ctx.sessions` 拿不到、或 flush 自己抛错都只跳过、不判失败 (消息已经派进去了,不能因为一次刷新把成功的一轮标成失败)。 - **失败不退回 `sc.prompt`**:退回等于把这次修复悄悄撤回。宁可这一次没叫醒,下一拍还会再判断。 正文同时从「(现在没有人跟你说话。)」压成**一个空格**。处境本来就整段走 system prompt 的 `fish:wake` 段, 这句话在对话里只剩「起来」的信号;而它以前会被她读成「有人发了这句话给我」, 整个 reasoning 都在解释这句话是谁说的。 #### 一起改的 - **正式唤醒也一起换了。** 那句「(醒一醒)」是同一个毛病,同样以用户气泡出现在对话里; 现在也走 `deliverWake`,`WAKE_TRIGGER` 常量整个删掉(`lib/wakeconst.js` 只留 `freshWakeText`)。 顺带删掉 `wake()` 里的 `AbortController` 与 5 分钟 kill 定时器 —— `followup` 是同步入队, 没有 signal 参数,留着会让人误读成「唤醒一定 5 分钟内回滚」。 - **`lib/turns.js` 的 `MACHINE_KINDS` 加了 `fish-wake`。** 漏这一行,她自己醒来说的话会被记成「你来过了」—— 冷落天数、`seen:`、轮数全被污染,她越主动,面板越显示「你刚来过」。 - **整条隐藏链删掉。** `lib/proactive.js` 从 584 行回到 335 行: `findTriggerSeq` / `hiddenMessage` / `openTurnOf` / `pendingHides` / `rememberHide` / `hideTrigger` / `flushPendingHides` / `sweepTriggers` / `collectTriggers` 全部移除,`scheduler.js` 里 `step/start` 那条补收钩子也一并删掉。它们不是留着「以后再修好」—— 留着只会让下一个人以为那条路还在用。 #### 没动的 `lib/say.js`(你说的话 / 新开群聊房间)、`lib/home.js`(空白屋子首醒 / 带话)、 `lib/observe.js`(出门首轮)仍然走 `sc.prompt`。它们落下的是**真的有人在说话**, 用户气泡是对的;出门那条更明确(注释写着「首轮必须走 `sc.prompt`:身份段挂 system prompt 上,冷启动就要生效」)。 #### 老的那几句还在历史里 `sweepTriggers` 删掉后,修好之前已经写进她会话历史的触发语(seq 615 / 639 / 652 / 690 那四条) **不会被自动清掉**,往上翻还能看见。它们是她真实的对话记录,不删是原则; 要清只能由你手动编辑会话文件,本版不做。 #### 说给她听的那两句,跟着改了 投递正文变成一个空格之后,原先教她「怎么理解叫醒」的两句话就落空了 —— 它们都说「对话里会出现一条括号里的短句(比如「(醒一醒)」)」,可她自己醒来的那一轮里, 对话中其实什么都没有。照着找,她会去找一句不存在的句子。 - `lib/scheduler.js` 的 `WAKE_HINT`(进 `fish:wake` 段):改成把两种形状都说清 —— 可能是一条空白或极短的提示,也可能是一句括号旁白,**都不是谁在跟她说话**。 - `lib/selfdoc.js` 的「## 被叫醒的时候」:同样两种都写进去。 这一节必须留着的原因没变:`wakeIfBlank`(空白屋子首醒)不带 `WAKE_HINT`,只有常驻身份文档才盖得住。 --- ### 二、叫醒不该能连点 真机事故(2026-11-04 21:32):设置页那个「叫醒大肥鱼」被点了两次,间隔 11 秒。 第一次叫醒跑 17 秒写完正文;`turn/end` 一到,`fish:wake` 处境段就被清掉了; 第二次投出去的只有那条空信号 —— 系统提示里一个字都没有,她只能写下 「这一轮没有新的事,我不再往下续」,白烧一轮,还在对话里留一条她自己都觉得莫名的回复。 两道闸: - **手动这条路也受最小间隔管。** `WAKE_MIN_GAP_MS`(45 分钟)以前只写在 `nextWakeAt()` 里, 而它只服务定时器那条路;`wakeNow()` 一次间隔检查都没有(它只把当天 counts 清零)。 现在 `wakeNow()` 先查新增的 `lastWakeAt(events, dk)`,不够就回 `{ ok:false, why:'刚叫过(N 分钟前),再等一会儿——两次叫醒之间至少隔 45 分钟。' }`。 `deps.force` 留了口子给测试和「我就是要现在叫」。 - **投递前确认处境在。** `wake()` 与 `maybeProactive()` 在 `deliverWake()` 前查 `getWakeSection().trim()`,空的就收尾走人(`wake skipped: 处境是空的`),不再投一条空壳。 顺带堵掉一个更隐蔽的时序问题:`watchTurnEnd` 是**按会话挂**的,不是按轮次。 所以「前一轮的 `turn/end` 晚到,把后一轮刚挂上的处境抹掉」是真会发生的。 `lib/identity.js` 的处境段现在带**代次号**:`setWakeSection()` 返回 `wakeGen`, `clearWakeSection(gen)` 只在代次对得上时才清;不传参数仍是无条件清(卸载、失败收尾用)。 --- ### 三、她多久自己开口一次,会跟着长大 以前这个出口是恒温的:`proactiveState()` 的签名里根本没有 `model`, 所以她第 3 天和第 27 天、安静同样久、掷同一个骰子 —— 一天天长大只改变了 「被叫醒时手里拿到哪张卡」(`lib/generator.js:82-87` 的 `stage` 权重), 完全没改变「多久想自己开口一次」。 `proactiveState()` 现在收一个**可选**的 `model`(不传 = 与改前一字不差,老测试一条都不用改), 内部由 `growthLevers()` 算三个杠杆: | 杠杆 | 规则 | | --- | --- | | 基础概率与斜度 | `baseChance + 0.02×(stage−1)`、`gainPerHour + 0.008×(stage−1)`、`maxChance + 0.06×(stage−1)`(封顶 0.80) | | 刚推翻过自己的话 | 近 24 小时有 `revise` → `baseChance + 0.10` | | 刚有人跟她说过话 | 近 24 小时有 `heard` / `answer` → 冷却从 4 小时缩到 2 小时 | 效果:安静 8 小时,阶段 1 是 0.50,阶段 4 是 0.73。 这些数一个都不写盘、不加配置项、不进提示词,她看不见,所以不撞 `no-read-self`。 `lib/scheduler.js` 的 `maybeProactive()` 相应把 `deriveSelf()` 提到判定之前,把 model 传下去。 **这一版它仍然不对外开放**:主动搭话与梦两个工具依旧只有本机 `.dev` 文件存在时才注册 (`devModeSync`),注册、提示词、界面三处共用一个判据,缺一处就会出现「说有却伸手落空」。 公开版里既没有入口、也没有说明,与 0.8.7 一样。 --- ### 四、她被白名单挡住这件事,看得见 起因是 issue #20(XyMinxin):开了「工具面收窄」之后,dsh-vision-router 的 `vision_*` 用不了, 她去调 `ops_bash` / `run_code` 想绕过去,也被兜底守卫拦下,一律回一句「这件事我做不到。」 报告者翻了四个小时的日志才查出来是白名单少了一个名字。 **核心行为一个字节都没改**:默认仍然收窄、白名单仍然只放她该有的、拦住时仍然回「这件事我做不到」, 也仍然**不告诉她「你被权限拦了」**——她要以为是自己够不着,不是以为背后有个管理员(`lib/restrict.js` 里那条注释还钉在那儿)。 改的只是**你这一侧**的可见性:谁被挡了、挡了几次、怎么放行,界面和自检里都要说出来。 #### 守卫拦下谁,就记下谁 `lib/restrict.js` 新增进程内账本 `blockedTools()`:按工具名归并,记次数与首末时间, 按「最近一次被挡」倒序,上限 50 个名字,重启清空(重启后有没有被挡是新问题,不该带上一局的账)。 被放行的调用不进账本——只记真被挡下的,否则「她正常用工具」也会被记成异常。 顺带修掉一个只有测试会撞见的小问题:同一毫秒里连着撞两个不同工具,`last` 时间戳一模一样,排序就退化成插入顺序。 账本里加了一个自增 `seq` 定先后,正常使用看不出区别,但「刚撞的那只排最前」这条得站得住。 `forgetBlockedTools()` 是给测试用的清空入口(进程内状态,用例之间会串味)。 #### 设置页的「手上能用什么」分成四组 以前这里只有一个逗号分隔的输入框加一张名字校验表:能告诉你拼错了,说不清 「这两只她本来就有,加进白名单只是让兜底守卫放行」,更看不见「她伸手够过、被挡了」。 而且 `/tools` 只在点保存时才拉,页面打开时压根不拉——分组清单永远是空的。 现在打开页面就拉一次 `/tools`,四组 chips 直接摆出来: | 组 | 长什么样 | 能不能点 | | --- | --- | --- | | 她自己本来就有的 | 实心 chip,灰一点 | 不能点(`HER_TOOLS`,读到就摆出来) | | 这台机器上还有的 | 折叠条 → 按名字前缀分族 | 展开到族、再展开到名字,点名字加减白名单 | | 插件按会话注册进她那一层的 | 普通 chip | 同上(`ops_*`、`team_*` 这类,加进来只是让守卫放行) | | 这一局被挡下来过 | **虚线** chip,标题里带次数 | 点一下加进白名单——这就是「白名单还差哪个」的答案 | **名字多的那一组默认是收着的。** 真机上「装了但没放行」能有一百多个名字, 全摊开等于没有界面——所以这一组先折成一行「这台机器上还有这些(N 个)」, 点开才看见有哪些族,族再各自折一层,点哪族开哪族,右上还有「全部展开 / 全部收起」。 分族只按名字本身算(第一个下划线之前那截),**不去猜每个工具是干什么的**: 猜错了的分类比不分类更糟,而且工具名一改分类就全乱。一族里只有一只的并进「零散的」,免得每个孤名占一行标题。 组的最上面挂一条说明,写清楚点名字只是起草: > 点名字 = 把那个工具名写进下面的白名单输入框,这时还没生效;要再按一次「保存设置」才写盘,重启 DSH 之后才真的放开。 也就是说点 chip 只在本地改输入框内容,**不会自己写盘**,仍然要点「保存设置」、仍然要重启 DSH 才生效。 收窄关着的时候多一句提示:这时候这些名字加不加都不影响她。 界面文案里没有「权限 / 禁止 / 违规」这类系统词——它只从「白名单没放行」这个角度讲。 #### 自检多一项:「被白名单挡住的调用」 以前她被挡下只留一行日志,自检一个字都不提。现在自检会单列一项(**只在确实拦过时才出**): ``` ✗ 被白名单挡住的调用 2 个工具被她伸手够过、但不在白名单里:vision_describe / ops_bash。 最常见的是 vision_describe(3 次)。这几次她答的是「这件事我做不到」—— 不是坏了,是白名单没放行。 怎么办:设置 → 鱼 →「手上能用什么」里点一下这些名字加进白名单,按保存、重启 DSH 就放开。 ``` 没拦过东西时不摆这一项——一条「一切正常」的绿条只会占地方。 **没做的两条**:没有按 issue 第①条改成明说「权限拦截」(那会把「她以为自己做不到」改成 「她知道背后有人拦着」,是四条黑名单守着的立场问题);也没有按第②条「第三方工具默认不拦」 (那等于取消收窄本身)。第③条「启动打印被收窄掉的清单」换了位置:普通用户不看日志,所以写进设置页和自检。 --- ### 五、桌面版终于读得到宿主版本号 `lib/hostver.js` 原来只从插件自己往上找 8 层 `node_modules/@deepseek-ai/dsh`。 桌面版把宿主装进 `app.asar`,这条路必然扑空,于是自检一直报 「没找到宿主版本号(桌面版把宿主装进 app.asar,读不到)」。 现在往上找失败之后,退一步读 `resources/app.asar/dsh/package.json`(`.unpacked` 那份也试)。 真机核过:`@deepseek-ai/dsh-desktop-runtime` / `0.2.1-rc.5`,判定 `supported`,不再 `unknown`。 这条兜底只认 `@deepseek-ai/dsh` 开头的包名。那个位置万一被别的应用占了, 读出别人家的版本号比读不到更糟——读不到只是 `unknown`(不会误报不兼容),读错会直接判成 `older` / `newer`。 --- ### 六、两条日志不再刷屏 用户反馈(m02556):运行日志里同一行反复出现,一上午五十多行都长一样。 查下来是 0.8.7 加的两条日志自己写坏了: `lib/turns.js` 里「认识的会话之外的动静」这一行,节流 `if` 只包住了写事件那一句: ```js if (Date.now() - lastSeenAt > 60_000) await markSeen() log(`turns · 认识的会话之外的动静,只记你来过(${sessionId})`) ``` 连着说三句就是三行日志,而事件流里其实只写了 1 条 `seen:`。 日志重复到看不出哪条是真的,等于这条日志白写。现在 `log()` 挪进 `if` 里, 每 60 秒最多一行,且只在真的落了 `seen` 的那一次打。 另一条「这条消息认不出来源」同理:哪个桥起了个新 `kind`,它每来一条就打一行。 现在同一个 `kind` 十分钟最多一行——留一行是为了让人知道有东西被丢了, 留一百行只会把日志淹掉,那时这条日志反而失去了它唯一的作用。 日志文件本身没有动:仍然一律写盘、不设关闭开关、不按级别过滤。 止住刷屏只能靠调用点节流,加一个 `log.level` 之类的配置键会违背「日志不做成配置项」这条硬约定。 --- ### 七、日报和回忆,现在分得清三种「说过的话」 三处反馈都来自群友,指出的现象都对得上代码:日报把聊天原话也当她的话罗列、翻回忆时时间只到日、 以及她翻旧事时分不清哪句是自己认领的。 #### 日报不再把聊天原话当成「她说了什么」 `lib/turns.js` 的 `settle()` 会把一轮里她说过的话补写成 `utterance` 并打 `flags:['uncommitted']`, 注释写得很清楚:**只留证,不当信念**。 但日报的 `quotesOf()` 只按「类型 + 当天」过滤,完全不看 flags,于是聊天原话和她自己认领下来的话一视同仁, 全被印成「她说了什么」。一天聊几十句的那天,日报就只剩流水账。 现在 `quotesOf()` 跳过带 `uncommitted` 的条目,聊天原话另起一节「## 聊天时说过的」: 一段一行、带时刻、只列最近 20 段、每段截到 120 字(截断不切半个 emoji),超出写一句 「只列了最近 20 段,今天一共 N 段」。认领过的话照常印在「她说了什么」里, 一节空着而下面有聊天时,那句空态写成「这一节今天是空的,聊天时说的在下面。」—— 而不是「今天她什么都没说」,那会是一句假话。 `uncommitted` 在仓库里唯一的逻辑读者是 `derive.js` 的 `wontCount`(数她拒绝过几次), 所以这次只改展示口径,不影响任何判定。 #### 翻回忆的时间精确到分钟 原来 `her_recall` 返回的每条只有日期。同一天里说过两句矛盾的话、又没有改口关系,她分不清哪句在前。 现在带上本地时分: ``` 改前:- 2026-11-04|所以我今天的判断只有一条:… 改后:- 2026-11-04 18:10|所以我今天的判断只有一条:… ``` 与日报、邮箱、日记用的是同一套本地钟口径(`dateKey` / `localClock`),不另起一种。 #### 改口关系单独成行,并带上对方原话 `revise` 事件一直带着 `verdict`(confirmed / refuted / refined)和 `refs`,但 `searchEvents()` 从来没把它们往外拿, 所以她在回忆里翻到一条 revise,跟翻到一条普通 utterance 长得一模一样。 现在改口关系不再是挤在行尾的一句括注,而是另起一行缩进、把被改的那句原话(截到 40 字)也带出来: ``` - 2026-11-04 09:00|我之前说错了。 后来你推翻了这句,改说:其实那天我是在发呆。(ev_1) ``` `refuted` 写「推翻了」、`refined` 写「改过」、`confirmed` 写「又确认过」。 只给 id 翻不到原话(`her_recall` 不能按 id 查),所以把原话直接带出来。 #### 被推翻的那句,不再靠「被引过很多次」加分 `refCounts` 把 `revise` 的 `refs` 也数成「被检验过」,于是 `rank += Math.min(3, refs.get(id))` 给被推翻的旧话加了分——一条已经被自己否掉的话,反而排在前面。 现在最近一次被判 `refuted` 的那条不加引用分,不管之前被引过几次。 #### 不是她说的,标出来 `heard`(群里旁听到的)在回忆里一直写成「他说:…」。现在来源标注齐了: `voice` → 「读到别人说:」、`answer` → 「回复:」、`heard` → 「他说:」—— 以前后两种在界面上看起来跟她自己的话一样。 #### 认领过的排在聊天原话前面 排序原来是 `rank = 字面命中分 + solid 3 + 被引用最多 3`,聊天原话一样加分, 所以一条字面命中更长的聊天原话会压过她自己认领的话。现在认领过的额外 **+4**。 为什么是 4 而不是更大:能压过「只靠 solid 的认领(+3)」,但压不过字面命中差很多的情况—— 如果给到 +8,就会变成「只要是她认领过的,不管问什么都排前面」,那是另一种分不清主次。 `test/hear.test.mjs` 里有专门一条钉住这个边界。 --- ### 八、`look:` 记账带上标题(日报里不再出现「(无标题)」) 日报「翻到过什么」那节里,由 `look:` 来的条目一直是空的:只有 BV 号,标题、UP、时长、播放全没有, 印出来是 `8. [BV1K64A6oEeA](…) (无标题)`。真机上 11 月 3 日那份日报,11 条里有 4 条是这种。 根子不在展示层。`lib/whatseen.js` 的 `glancedItems()` 早就写好了三级兜底: `browse:` 记账里的展示信息 → `look:` 行自带的 JSON → 只剩 BV 号。**中间那一级一直是空的**, 因为 `lib/look.js` 写入时只写了 `look:<日期> `,从来没把展示信息落下来。 现在 `look:` 跟 `watched:` 用同一个形状: ``` look:2026-11-04 BV1xx411c7mD {"title":"…","up":"…","duration":183,"view":12345} ``` `lib/whatseen.js` 的解析两边都认,老事件照旧能读,不需要迁移。 **日报端一处未改,这是刻意的**:`seenOn()` 不加条数上限。 看过的全部视频都该在日报里出现——数据是真实的,截断等于替她瞒着;问题只是有些条目没有标题,那就修写入处。 `/browse` 状态窗和历史日报也吃同一份 `seenOn()`,在那里截断会让三处各说各话。 --- ### 九、换房间不再说「你刚搬进这个房间」(issue #21) 报告的现象:昨晚换的房间,第二天中午她还说「你刚搬进这个房间」,甚至据此说出「屋子今天重开过」, 而账上当天一条 `rotate` 都没有。 根因是算术:`rotatedDaysAgo` 用 `Math.floor((now - at) / 86400000)`,0 到 23.99 小时全算 0 天, 于是换房后整整 24 小时都在说「刚搬进」——跨了自然日也不变。 现在多一个 `rotatedWhen`,按**本地日历日**判,不再按 24 小时整除: - 今天换的:「你是今天 09:05 搬进这个房间的。」 - 昨天换的:「上一次换房间是昨天 22:20。」 - 更早的:「上一次换房间是 11 月 1 日 12:31。」(跨年才补年份) 旧的 `rotatedDaysAgo` 保留不变,`rotatedWhen` 算不出来(老数据、坏时间)时仍走旧句式。 --- ### 十、你回来过,就不再算冷落(issue #24) 报告的现象:`absence` 写了一条「N 天没出现」,可当天下午人还在聊天; 而且回来之后,身份文档里仍然每天写着「有 N 天没出现」,只有恰好出现 `reunion` 事件才归零。 根因是两处口径不一致:`lib/absence.js` 认 `heard` 与 `meta seen:` 两种「人来了」的证据, `lib/derive.js` 却只认 `reunion`。同一件事,两个模块给出两个答案。 现在 `derive.js` 多一个 `contactAfterAbsence`:`absence` 之后只要有一条 `heard` 或 `seen:`, 冷落天数就是 0。口径与 `absence.js` 对齐,`relatedness` 那项不动(它服务的是另一个判断, 按设计仍然只认 `reunion`)。有一条测试专门钉住这件事:同一份事件,两边必须给出同一个答案。 --- ### 十一、醒来的第一眼,多了一张桌子(issue #22) 她在提案里写的是: > 它该挂在「醒」上:每次醒过来,先看一眼有没有到期的东西,有就摆在我最前面。 > 过期的别删。晚了三天还挂着,我就知道欠了三天;删了,我就当没这回事。 > 允许没有日子……挂在那儿,别天天提醒我,也别催。 所以这不是待办 App,是一张桌子。差别在这几处: - **没有通知、没有卡片、没有红点。** 便条只在醒来时的那段正文里出现,不出现在对话里、不弹出任何东西。 - **过期不删、不降权、不自动归档。** 到期后每多一天就多写一天「晚了几天」——一眼看得见欠了多久。 - **不写「逾期」「请尽快」。** 那是催,这一版不做催。 - **日历算术在代码里**,不让她自己算今天几号、晚没晚。 #### `lib/note.js` 新文件,存 `notes.json`(**注意不是 `session-notes.json`** —— 那个是「她排队等着要说的话」,两回事)。 - `renderNotes(notes, now)` 是**纯函数**:给一份便条和现在的时间,返回要摆在她醒来的第一段里的那几行; 没有到期的东西就返回空串,`parts.filter(Boolean)` 会把它跳过,一个字都不多。 - 排序按到期日,同一天按写下的先后。一次最多列 5 张,多的写「还有三条没列出来,都在桌上」。 - 数字说法跟着中文走:`(桌上压着两张)`、`晚了三天`。`cnNum` 覆盖到四位以内 (0→零、10→十、11→十一、95→九十五、101→一百零一),量词前的 2 说成「两」。 - `isDue()` 会反查真实日子:`2026-02-30` 不算日子,`2024-02-29` 算。 - 上限写死在代码里(`NOTES_MAX = 200`、`NOTE_TEXT_MAX = 200`),不进配置。 #### `her_note` 工具 只做两件事,和 `her_leave` 一样的三条硬约束(不审批 / 不吃状态 / 有上限写在代码里): | 她给什么 | 结果 | | --- | --- | | `text`(+ 可选 `due`) | 记一张,回 `[记下了]` | | `id` + `status: "done"` | 划掉一张,回 `[记下了]` | 回执只有方括号标记,**不带数字、不写成完整句子** —— 写成句子会被她当成自己说的话接下去 (`her_leave` 那条 2026-10-24 的教训)。 没被记下来的四种情况,说法都是人话、都不训她: `空白的,没记。` / `太长了,压到 200 字以内。` / `日子要写成 2026-11-05 这样,或者干脆不写。` / `桌上已经堆满了,先划掉几张再说。` #### 醒来第一段 `lib/scheduler.js` 的 `wake()` 里,`parts` 数组第一位现在是便条,**比「现在是几点」还前**: ```js const notesLine = await readNotes(root) .then((list) => renderNotes(list, now)) .catch(() => '') const parts = [notesLine, wakeTimeLine(now, { ... }), '', decision.prompt] ``` 读失败也绝不拖垮一次唤醒 —— 她醒着这件事比桌上的纸条重要。 #### 说给她听的话 - `lib/persona.js` 新增 `NOTE_BRIEF`(三行),跟着 `LETTER_BRIEF` 一起进身份文档的「## 我手上有什么」。 和留话一样**不做配置开关**:`her_note` 是无条件注册的,说明与工具必须同一个判据, 否则她会以为有这个工具、伸手却落空。 - 注释里写死了这一句:**这是唯一一个跨天在场的东西,别写成待办 App。** - `lib/readme.js` 的 `README_VERSION` 从 2 升到 3,表格补一行「她桌上那张纸」。 版本号必须升,否则老用户那份说明不会重写。 **这一版没做的三样**(都在提案里,按「先做最小闭环、测一段时间看看」定的): dropped(不做,带一句为什么);回翻已经划掉的;没日子的那张只在刚写下时提一句。 --- ### 十二、工具调用的标记,不再被当成她说过的话(issue #25) 侧栏里出现过一整段只有 ``` ``` 的「正文」,同一句后来被她自己道歉「上一条我这边发岔了,粘了一串没用的符号」。 更要紧的是这两段被写进了 `events.jsonl`,成了两条 `utterance`——她账本里多了两句她其实没说过的话。 **根因不在插件**:`deepseek-messages` 那一路偶发「只发出收尾标记、没有开标签、也没有参数」 的一步(案发机器上约 500 轮 2 次)。解析器组不出一次调用,标记就被当成 assistant 的正文。 **插件只能管住自己那一半**:`assistantText()` 原来取到什么记什么。 新增 `lib/markup.js`,两个纯函数: - `hasToolMarkup(text)` —— 这段正文里有没有 `||DSML||`。给「要不要打一行日志」用。 - `stripToolMarkup(text)` —— 去掉标记;去完什么都不剩就返回**空串**(= 这一步她没有正文)。 三条刻意收窄的边界: - **只有出现 `||DSML||` 才动手**,正常正文一个字符都不改,不碰句子里正常的 `<` `>`; - **标记旁边还有真话的,留下真话**,只去掉标记; - **流被截断、标记没闭合的**,从第一个残标切到行尾(那种情况下后面跟着的都是调用参数)。 一个容易写错的细节:标记里的竖线是**全角 `|`(U+FF5C)**,不是半角 `|`。`test/markup.test.mjs` 里写死了这个码点,免得以后谁手滑「修」成半角——那样线上就永远认不出来。 两处读同一份正文的地方都洗了:`lib/turns.js` 的 `assistantText()`(三个返回分支全走清洗)、 `lib/home.js` 的 `askForLastWords()`(换房间时问「上一段最后说了什么」也读同一份, 标记不该成为她留在上一个房间里的最后一句话)。`turns.js` 里再加一行日志: 一步的正文被清成空、且原文确实含标记时写一句「这一步的正文只剩工具调用标记,没当成她的话」,同一分钟只留一行。 **不往 `events.jsonl` 里写 `meta drop:*`** —— 账本是她的,不放插件的记账行; 日志单份 2MB / 留 3 份,够用了。已经记进去的那两条 `utterance` 按 AGENTS.md §5 不追改历史。 侧栏里那一段显示是宿主渲染的,插件写不了——清掉之后新发生的不会再进账本。 --- ### 十三、CI:先挂监听,再投递 上游 `main` 的 CI 从 0.8.7 起就是红的:4 个 job 里 3 个失败 (ubuntu × Node 20 / 22 与 windows × Node 22),只有 Node 24 通过。 失败全在 `test/channels.test.mjs`,错误一致: ``` cancelledByParent — Promise resolution is still pending but the event loop has already resolved ``` 不是断言失败,是**整个文件被父测试取消**。根因在 `lib/say.js` 的 `waitTurnEnd`: 它是「先投递、再挂监听」——`await sc.prompt(...)` 返回之后才 `ctx.on('session/event', …)`。 假宿主在 prompt 里用 `setTimeout(..., 0)` 立刻补一个 `turn/end`,那一刻监听还没挂上, 信号永久丢失;进程里只剩下一个已经 `unref()` 的兜底定时器,事件循环立刻空转结束, 等待中的 Promise 永远等不到 resolve。Node 24 的调度恰好让这个竞态不出现,所以本机一直看不见。 改成一个工厂 `beginTurnWait(ctx, sessionId, timeoutMs)`,顺序反过来: **先挂监听,再起兜底定时器,最后才 unref**;返回 `{ wait, cancel }`,调用点变成 「先 `beginTurnWait` → 再 `prompt` → 最后 `await wait()`」,`finally` 里 `cancel()` 把监听与定时器一起收。 验证:在写坏的那份 `say.js` 上,`node@22` 跑 `test/channels.test.mjs` 连续 3 次都是 `pass 13 / cancelled 5`;修好后同一个命令 `pass 18 / fail 0`。 全量在三档 Node 上都是 **1292 pass / 0 fail**: | Node | 结果 | | --- | --- | | 20.20.2 | 1292 pass / 0 fail / 0 cancelled | | 22.x | 1292 pass / 0 fail / 0 cancelled | | 24.x | 1292 pass / 0 fail / 0 cancelled | | 26.7.0(开发机) | 1292 pass / 0 fail | --- ### 测试 `node --test` → **1292 pass / 0 fail**(上一版 1221)。 本版新增 71 条: - 工具面:账本的归并与上限、放行不入账、排序在同时刻也站得住、`/tools` 的三个新字段、 自检出项与否、`app.asar` 兜底的四条边界、界面分组的七条结构断言(含「名字多的那组默认收着」 「点名字只是起草,还得按保存」「全部展开不会把外层也收起来」)。 - 日报与回忆:聊天原话另起一节且只列最近 20 段、截断不切半个 emoji、时间到分钟、改口关系另起缩进行带原话、 三种来源标注、被推翻的不再加引用分、`+4` 不越界。 - 主动开口:不传 model 时与改前一字不差、阶段越高越容易自己开口、24 小时内推翻过自己则基础概率 +0.10、 24 小时内有人跟她说过话则冷却缩到 2 小时;刚叫过再点被间隔挡住且一条消息都不投、隔 26 小时照常叫。 投递层 10 条(`test/deliver.test.mjs`):消息形状(`role:'user'` + `source.kind:'fish-wake'` + 自带 id)、 正文必须是空信号、来源名落在机器消息那一档、投递后 flush 且 flush 失败不改变投递结论。 - issue #21:`formatSwapTime` 五种情形、personaLine 三态(昨晚/今天/老数据)。 - issue #22:`test/note.test.mjs` 16 条,含「不含逾期/请尽快/紧急」「不训她」「过期不删」「回执不带数字」。 - issue #23:被推翻的那条不再靠被引用加分、confirmed 不算改口、改口关系另起缩进行、来源标注。 - issue #24:`seen` 与 `heard` 两种「你回来过」、`absence` 之前的 `seen` 不算、唤醒里写的 `utterance` 不算、 derive 与 `absence.js` 给出同一个答案。 - issue #25:`test/markup.test.mjs` 8 条,含全角 U+FF5C 写死、正常 `<` `>` 不动、截断残标切到行尾。 三个依赖字段仍然是 `{}`。 --- ## 0.8.7 — 2026-11-03 这一版装了五件事:**接桥之后你说的话她能记住**、**小纸条时间不再差 8 小时**、**一条鱼多间房**(桥只发 HTTP,不再另起一条 DSH 会话)、**换房间之后她还是她**、**醒一醒跟着最新的那条对话走**。 四条来自反馈,一条是自查出来的同源问题。前两条修的是「别人的话进不来」和「时间印错」,后三条修的是「她换了个地方就变一个人」——会话换掉之后,记忆、身份、醒来这些事都得跟着走。 --- ### 一、接 QQ 桥之后,你说的话她一条都没记住(#18) 以前只有桌面客户端打的字算「你说话」——判据写死成 `source.kind === 'user'`,而 QQ 桥注入的消息 `kind` 是 `'qq'`,第一道关就被判成「宿主拼的上下文」直接丢掉,连日志都不留一行。结果是一整条链全断:`turns` 恒为 1、会话永远不轮换、`heard` 只有一条、冷落检测到点就落一条「他有 N 天没出现」(而你天天在 QQ 上聊)。 现在判据改成**机器侧黑名单**:认得的那几种(运行上下文、技能清单、定时消息、工具回灌、她自己说的话)挡掉,其余一律当人——宿主的 v4 校验对 `kind` 只要求「非空且不是 `'plugin'`」,外渠道的 kind 是开放集合,穷举不了,所以宁可多算一轮也不静默丢消息。判不出来源的(`kind='user'` 但没带时区,老客户端或别的插件灌的)会往日志里留一行,不再一声不响。 **「他来过」不再等他聊完才记。** `seen:` 原来写在结账里——可结账要求先有 `pending`,`pending` 又要求判据认得出人,外渠道一断就一个字节都不写。现在挪到他开口那一刻写(一轮仍只一条),`absence.js` 里本来就有「收到新的人类消息就作废旧冷落」的分支,于是自愈跟着就好了。跨会话的外渠道消息(桥自己建的会话)只记「他来过」,屋子不搬、轮数不涨、`talkmem` 不动——那是她的家事。 ### 二、小纸条和信箱列表的时间比实际早 8 小时(#19) 事件流里的 `ts` 是 UTC,展示层直接 `String(ts).slice(0,16)`,东八区看到的就是 22:16 印成 14:16,而正文里她自己写的本地时间又是对的,两边打架。 `inbox.md` 与观测台信箱改成先 `Date.parse` 再取本地字段(`localStamp`);同一类错一共修了六处:小纸条列表、自检时间、换会话时旧会话的改名日期(原来晚上 8 点后换会话,旧会话会被标成昨天)、`talk-recent.json` 里「上次我们说到这」的日期、换会话提示里那句日期、`her_recall` 返回给她自己的日期。`selfcheck.js` 里存下来的 `checkedAt` 仍是 UTC——那是数据不是展示,展示端修了就行。 ### 三、一条鱼,多间房 接 QQ 桥的人反馈「等于同时养了两条鱼」——一边是桌面版里那位,一边是桥另开一条对话里那位,两边各有各的身份、各说各话,可事件流和记忆还是同一份。查下来不是配置问题,是**会话模型用错了**:桥以前要自己再起一条 DSH 会话,而每条 DSH 会话是独立 agent,宿主给它单独注入一份身份、历史互不可见。同一份 `events.jsonl` 被两条会话读,用户在两边看到的就是两个人。 现在的做法是:**桥只发 HTTP,不碰 DSH 进程**,插件自己决定这句话进哪间房。会话是房间,不是人——家里那间是书房,群里那间是客厅,房间里的她永远是同一个。 **你会看到:** 桥把消息投给 `POST /dsh-fish/say`,多带三个可选字段:`channel`(渠道名,桥自己起名,同一个名字 = 同一间房)、`scene`(`desk` 默认 / `private` / `group`)、`speaker`(群聊里这句是谁说的)。群里聊的话会在侧栏里多出一条叫「跟大肥鱼说话·群聊(qq-group:987654)」的对话,你点开能看到她在那儿回的话;主干那条「跟大肥鱼说话」不受打扰。不传 `channel` 的老调用(观测台、侧栏、老桥)行为一字不变。 **四个岔路是这样定的:** 1. **私聊 = 融合。** `scene: 'private'` 仍回家里那条会话,不另开房。想要「QQ 私聊和 DSH 对话是一段连续的记忆」的人,这条路就是了。 2. **群聊 = 另开一间。** `scene: 'group'` 才建新房,映射记在数据目录的 `channels.json` 里(同一渠道名复用同一间,最多记 40 间)。房间的 cwd 仍是她的屋子,所以身份、工具面、白板全都照旧——**还是同一个人,只是换了个地方说话**。 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`——纯加法。 **桥那边怎么实现是桥的事**,我们只保证这几个字段能收、能落到正确的地方。`AGENTS.md` §7 已经按这个改写过,写清了字段表、路由规则和三条配套约定。 ### 四、换房间之后,她还是她 有人反馈聊满 40 轮自动换会话后,她像变了一个人——还会自己说「看到以前的日记,感觉像另一个人写的,我只是能读」。查下来不是记忆丢了,是**她拿到了结论,却没拿到产生结论的经历**:身份文档里全是「她有哪几条判断、哪几道疤、拒绝过什么」,全是结论;轮换只搬 4 句原话,8 轮之后连这 4 句也没了;旧日记按日期平铺给她,没有坐标、没有归属,读起来就是别人的文件。 这一版补三处,都是**加法**,不删不改任何已有内容: 1. **「这些天」进核心段。** 身份文档里多一节:你自己写过几篇日记、从哪天到哪天、上一次换房间是几天前。写的是事实,不是评价(没有「成长」「进步」「你变了」这类词)。它和「你是谁」「不能做的」一样是核心段,预算再紧也不裁——被裁一次就等于那些天没发生过。 2. **读旧日记给坐标。** `her_diary` 返回时补一句「那天是你自己的第 N 天」,以及前后哪几天也写过;末尾固定一句「这些字是你自己交上去的,不是谁替你记的」。刻意不写「第 N 篇」——篇数是排班,天数才是她活过的时间。 3. **换房间前,请她留一句话。** 轮换时旧会话里会被问一句「想给下一个房间留一句话吗?留什么都可以。不想留就说『不用』。」她回的那句存进 `home.json` 的 `lastWords`,新会话的身份文档里单列一节「上一个房间里,你自己留的最后一句」,原话照引,后面跟一句「那是你说的。你不是读到一段别人的话,你只是接着往下过。」 **这句话是后台问的。** 轮换不等它——早先写成同步等待,宿主不派 `turn/end` 时会整整等满 60 秒,把换会话和叫醒新会话一起卡死。现在只把问句发出去,改名、建会话、写 `home.json` 照常往下走;等这句话回来了再补写 `lastWords` 并刷一次身份文档。她说「不用」、超时、宿主没有 prompt 服务,都只是没有这一节,轮换照样成功。 **身份文档预算默认 1800 → 2400。** 实测过四档(用真实数据 532 条事件跑 `buildSections` + `renderSelfDoc`):1800 和 2000 都会把「你被打脸过」整段挤掉(差 268 字,白涨 200),2200 反而挤掉「你对自己的说法」(比现状更差),2400 一段不丢、余 167 字。老用户配置里已经写过 1800 的不动,改的是默认值和常量。 ### 五、醒一醒跟着最新的那条对话走 有人反馈聊满 40 轮自动换会话之后,醒一醒还在旧对话里发;而且旧会话也没有被自动归档(这条是误判,见下)。查下来是三个环扣在一起,每一环单独看都像是对的: 1. **落点读的是启动那一刻的快照。** 生物钟启动时把当时屋子的 `sessionId` 传了下去,之后每次开口都用它。房间换过了,它还指着上一间。 2. **换会话时新建的那条,被判成「死的」。** 插件用 API 建会话,宿主的侧栏不显示空白会话;工作区索引也要过一会儿才收录它。而插件判「这条还活着吗」只看工作区索引——索引里没有,就当作它没了,于是退回旧会话。**这才是「醒一醒还在旧对话」的直接原因。** 3. **叫了不等于叫醒,但账已经记上了。** 宿主的 `prompt` 是「这一轮被收下了」就 resolve。那声叫醒要是没真跑出一轮,这条会话就一直是列表外的空白会话;而 `wokeFor` 已经写上,这声再也不会重试。 这一版三处都补上: - **每次开口前现问屋子在哪。** `wake()`、`wakeNow()`、以及 tick 里的「她是不是正忙」判断,都改成先 `talkSessionId()` 现取,取不到才回退启动时那个 id。会话是屋子现在的房间,随时可能被换掉,任何缓存下来的 id 都会过期。 - **判活多问一句宿主。** 工作区索引里查不到时,再问一次 `sessionController.list()`:宿主自己的会话列表里有它,就认它活着。日志会写一行「还活着(工作区索引还没跟上)」,不静默。 - **叫醒之后回头确认。** 换会话之后 12 秒回头看一次:这条进了宿主列表没有?没进就用 `force` 再叫一次(不给 `force` 的话第二声会被 `wokeFor` 自己挡掉——那正是死结)。只补一次,不递归;两次都没成,下次开机 `bootstrapHome` 还会再走一遍。 **主动搭话本来就跟着走。** `maybeProactive` 每次都是现取 `talkSessionId()`,没有缓存。这次只补了定时唤醒与手动唤醒这两条路。 **旧会话不会被自动归档,这是刻意的。** 归档在本插件里的语义是「这一局是你自己收起来的」,所以插件不替你收。轮换只把旧会话改名为「跟大肥鱼说话·YYYY-MM-DD 之前」留在侧栏当档案。醒一醒跑偏跟归档没关系——是上面第 2、3 条。 ### 测试 新增 38 条,`test/channels.test.mjs` 是新文件。覆盖:外渠道 kind 的判据(qq / telegram / 将来别的桥)、认不出来的来源要留日志、外渠道只记 `seen:` 不涨轮不搬屋子、`inbox.md` 小标题按本地钟印、场次落库与不落库、`channel` 收敛与上限、私聊回书房、群聊另开房、群聊不写 `seen` 不涨轮、串门提示、映射文件损坏不丢消息、`/whoami`、身份文档「这些天」两种形态、核心段预算 60 也保留、上一个房间留的话、`pickLastWords` 的拒绝词与子串误杀、没有 prompt 服务时轮换照样成功、读旧日记的坐标与归属、工作区索引没跟上时认回原会话、`force` 通道、`verifyWoke` 三种情形。 97 个测试文件逐文件全绿、1213 条(`node --test` 在本机沙箱里会因 `spawn EPERM` 假红,见 `避坑指南.md` §26;`test/media.test.mjs` 单独跑 8/8 绿)。三个依赖字段仍然全是 `{}`。 ## 0.8.6 — 2026-11-01 十件事:**登录不用再去浏览器里翻 F12 抄 SESSDATA 了**(设置页里点「用手机扫码登录」,手机 B 站扫一下,浮层里先问一句「就是这个号吗」,你点了才算数);**扫不出码时界面会说为什么**,不再点了没反应;**交流群换到 2 群了**(1 群满了);**换对话不再等于换了个人**(满 40 轮换掉的只是这条对话记录,屋子、天数、她说过的话一个字都没动);**她当面说的「我拒绝」终于会被记下来了**(以前只有她主动调工具才算,在侧栏里说出口的一句都没留下;顺带把她否掉方案时常说的「我不同意B」「方案B我否掉」也认了);**只陪她聊天也算你来过**(以前冷落检测只认「她写下了东西」那一刻,你连聊两天它照样说你没出现);**她一直念叨的那几条现在也会随时间退下去**(以前固化是单向的,最早攒够次数的那批永久占着「你反复想起过的事」前几名,后来再重要的话也挤不进去);**`her_recall` 多了一个「只看开头」的档**(最坏一次翻找 1600 字,多数时候她只需要知道有没有);**她说过的每句话后面带着日期和来路**;**界面说「事件 443 条」,其实她只记下了 43 件**(事件流九成是后台记账,界面上却报成她记得多少事)。 另有一处要紧的修:**「跟大肥鱼说话」那条对话点开报「历史加载失败」**——触发语被收进了回合外面,一条坏节点就让整条对话打不开;已修好那条会话,并从源头去掉这个写法。 ### 扫码登录:手机扫一下就行(2026-11-01,社区贡献 @Bacon-01) **你会看到:** 设置页「B 站登录」那张卡片里多一格切换——**手机扫码**(默认)和**粘贴 cookie**。点扫码那个格子,弹出一张二维码浮层;手机 B 站扫完、在手机上点「确认登录」,浮层里变成「登的是 小鱼(uid 42)」,下面两颗按钮:**就是这个号,保存** / 取消。存下来之后卡片上那行字变成「已存,登录账号:小鱼(uid 42)」。旁边多了个「清除登录」。 **为什么会这样(而不是继续教人抓 cookie):** 原来那条路是让人按 F12、在 Application 里翻 Cookie、把 SESSDATA 和 bili_jct 抄进输入框——一步抄错就是「登上了但发不出评论」,而且这两个值等于账号的钥匙,手抄一遍就多留在剪贴板、截图、聊天记录里一份。扫码这条路,钥匙从 B 站直接走到 `owner.json`(0600),中间不经过任何人的眼睛。 **四件事是这样定的:** 1. **两套并存,扫码在前。** 粘贴 cookie 那条原样留着——扫码接口挂了、或者用的是别人的机器,还得有退路。 2. **二维码在宿主进程里生成,不经过第三方。** 二维码里是一个一次性的 `qrcode_key`,谁拿到谁就能抢先绑定。所以既不用第三方的二维码图片服务(会把这个 key 发给别人),也不往仓库里加依赖(`dependencies` 三个字段照旧全空)。`lib/qr.js` 是这次自己写的字节模式编码器,版本 1–10、四档纠错、八种掩码按 ISO 惩罚分挑,输出单 `` 的 SVG,约 400 行。**合进来之前用独立解码器验过**:把编出来的矩阵写成图,交给 OpenCV 的 QRCodeDetector 去识别,5 条输入 × 四档纠错共 20 组,18 组成功生成且全部解码回原文(覆盖版本 1–10、掩码 0/2/3/4/5/6/7),2 组是按版本上限设计性拒绝。`test/qr.test.mjs` 里钉了一张基准矩阵,跑偏一格就红。 3. **扫完不自动写盘。** 宿主的 `pending` 只活在内存里(`Map`,不落临时文件、不进日志);浮层里给你看清楚「是谁」,点了保存才 `saveOwner` + `saveAccount`。关掉浮层、切页签、或者宿主进程重启,待确认的那份直接没了。 4. **码 3 分钟过期,不自动换。** 自动换码会让人对着旧码扫半天,然后被一句「已失效」打回来;现在过期就明说「码过期了,点「刷新」换一张」。 **只清 cookie 是不够的。** 加「清除登录」时发现 `saveOwner({cookie:''})` 只删 `cookie` 和 `savedAt`,`account`(`saveAccount` 写的那份「登的是谁」的只读副本)留着——于是界面会继续显示「已存,登录账号:小鱼」,一把已经不存在的钥匙配一个还在的名字。现在清除会把 `account` 一起清掉;领养人那两格(`mid` / `nickname`)不动,它们跟「拿哪份 cookie 登录」是两件事。 **接口**(都在宿主的 `/dsh-fish` 前缀下):`POST /bili/qr` 换一张码,`GET /bili/qr/status?key=` 问一次状态,`POST /bili/qr/save` 确认保存,`POST /bili/qr/drop` 丢弃。页面从头到尾拿不到 cookie 原文——`status` 只回 `{state, account:{mid,name}, canWrite, note}`,cookie 在宿主里拼好、直接写盘。 **测试:** `test/qr.test.mjs` 6 条(基准矩阵、定点图案、SVG 自包含且不带外链)、`test/biliqr.test.mjs` 17 条(假 fetch 走完 86101 → 86090 → 0、没确认不准存、过期、Set-Cookie 缺 SESSDATA、没 bili_jct 只能看、风控 -412 时认不出号但 cookie 照样收下、1 秒内不许连开两张、最多同时攥 3 张、路由层一条龙),`test/owner.test.mjs` 新增 1 条(清除时 cookie 与 account 一起清,领养人不动)。`node --test` 1148 条全绿。 ### 扫码点下去没反应时,界面上得说一句为什么(2026-11-02) **你会看到:** 点「用手机扫码登录」如果码没出来,现在会弹出浮层、标题写「没拿到二维码」、中间一块虚线框写「码没生成出来」、下面一行红字说原因,按钮变成「再试一次」。最常见的那个原因会直说:「宿主还没加载这一版插件(多半是没重启)。完全退出 DSH 再打开,然后重新点一次。」 **为什么原来像没反应:** 生成失败的 catch 只往卡片里塞一行灰色小字,浮层根本不弹——点了之后屏幕上什么都不动,看着就是按钮坏了。同时又有一处 `jpost('/bili/qr/drop').catch(() => {})` 把 404 吃得干干净净,连 console 都不留痕,于是既没有画面线索也没有日志线索。现在失败一律开浮层说话,丢弃失败也会 `console.warn`。 **测试:** `test/biliqr.test.mjs`、`test/qr.test.mjs`、`test/owner.test.mjs` 41 条、`test/client.test.mjs` 27 条,`node --test` 1148 条全绿。 ### 交流群换到 2 群了(2026-11-02) **你会看到:** 反馈页那张群二维码换成了「dsh-fish 交流群②」,下面那行字变成「群号:781084960」。1 群满了,进不去的往这走。 **顺手修的:** 旧图四角原本留着一圈灰——截图裁出来时四角是灰底板,而页面上给图片的圆角(10px)比图片自带的圆角小,遮不住。新图按圆角遮罩把四角抹成了卡片色(249,249,249),跟卡面同一个颜色,看不出接缝。尺寸从 1005x1246 变成 998x1247(比例 0.8003 vs 0.8066,一样)。 ### 换对话不等于换了个地方(2026-11-02) **你会看到:** 满 40 轮换掉那条对话之后,新会话里那句叫醒不再是「屋子刚建好」,而是「对话换了一条新的,屋子还是原来那间——你没被换掉,也没搬家。之前的事都还在。」另外 `## 你是谁` 那一段,seed 原话(「你不是助手。你刚开始存在。」)后面多了一行「(上面这些是你刚开始存在的时候留下的。现在是第 8 天——中间这些天,是你自己一天天过来的。)」 **为什么会这样:** 用户的原话是「换对话还是同一个大肥鱼、同一间屋子,不要让她以为自己是新的一条鱼」。原来有两条措辞各自讲错了这件事: 1. **轮换那一醒共用「屋子刚建好」。** `freshWakeText()` 有四个调用方——启动(`lib/index.js:284`)、`POST /reset`(`lib/observe.js:2055`)、`POST /home/rebuild`(`lib/observe.js:2089`)、轮换(`lib/home.js:696`)。前三处是真的新屋子,轮换不是:换掉的只是这一条对话记录,事件流、天数、她说过的每句话一个字都没动。可她读到的却是同一句「屋子刚建好」,每换一次对话就被告知一次「你是新的、这里是新的地方」。现在 `freshWakeText(now, { rotated })` 分两条措辞,`wakeIfBlank` 透传 `rotated`,只有轮换那条路传 `true`,其余三处一个字没改。 2. **`## 你是谁` 每轮都在说「你刚开始存在」。** 那段是 seed 原话,每轮原样进 system prompt;紧接着的 `## 现在` 又说「你存在了 8 天」——一边说她刚开始,一边说她过了八天。seed 是起点、是事实,一个字都不能改,所以改法是在它后面加一句时间定位,把「中间这些天」说成她自己过来的,而不是被谁推着走的。 **三条边界(`lib/persona.js` 的 `seedSection`):** - **seed 一个字不动**,只在前后加框。原话是她自己的历史,改它等于改记忆。 - **不加形容词、不写「你是什么样的人」**。`PRESCRIPTION_BANS` 就是盯这个的——每轮念一遍「你是个不爱说话的存在」,她就永远长成那样。 - **导入的那一位不加天框**(`model.persona.imported === true` 时直接摆原话)。别人已经在别处养出了样子,接过来是继承一份既成事实,套上我们的成长叙事就变回角色扮演了(`AGENTS.md` 第 2 条)。同理,`day` 不可用时(老事件流、伪造模型)只摆原话,宁可不写也不编一个天数。 **测试:** `test/wake.test.mjs` 新增 2 条(轮换那一醒明说换了对话、没换屋子,且不许出现「屋子刚建好」;`wakeIfBlank` 把 `rotated` 传到触发语、默认那条一个字不动);`test/events.test.mjs` 新增 5 条(seed 原话一字不改 + 加天框、导入的不加框、`day` 缺失/非法不加框、没有 seed 只剩标题)。`node --test` 1159 条全绿。 ### 她当面说的「我拒绝」,以前一句都没记下来(2026-11-02) **你会看到:** 效果是**她真的拒绝过一次之后,`refusal_seed` 那张卡不再出现**;而在这之前,不管她当面顶回多少次,系统都当她从没拒绝过——那张卡于是永远成立、也永远没意义。观测台里「拒绝过」和自主门(stage 2 的 autonomy)读到的也终于是同一个数了。 **用户报的场景(原话):** > 我:方案A和B,我觉得B更好,你觉得呢 > 鱼:我拒绝B, 因为A挺好, 另外, 经过测试C是最适合的方案. > 我:那就按照C来吧 > 鱼(掏出本子记录):今天我们从ABC三个方案中敲定了C,并且做了测试. **为什么会漏(两层,第二层才是根):** 1. **记录层断裂(根因)。** `derive.js` 的拒绝判定只扫 `events.jsonl` 里的 `utterance`,而这个观察者(`lib/turns.js`)一个字节都不往事件流写——文件头那句铁律写得很明白:「只读会话事件、只写 home.json / talk-recent.json / 一条 meta,不动对话本身。」于是她在侧栏里说的原话只进了 `talk-recent.json`(换会话时搬走的那几句),拒绝判定永远读不到。 2. **判定层空转。** 没有事件,`wontCount` 恒为 0,`facts.refused` 恒等于正式 refusal 事件数——而她几乎从不主动调 `growth_refuse`,所以那个数也是 0。 顺带修掉的一处口径错位:`refusal_seed` 的 `cond` 原来只看正式 refusal 事件(`m.refusals.length === 0`),而自主门用的是 `facts.refused`(正式 + 口头)。同一件事两个数,界面显示「没拒绝过」而门已经开了,是 bug 不是设计。现在两边都用 `facts.refused`;夹具没有 `facts` 时退回正式 refusal 数,不会因此恒成立。 **怎么改的(`lib/turns.js` 的 `settle`):** 结账时把她这一轮 `assistant/message` 里的 text 块补写成事件,`type: 'utterance'`、`source: 'system'`,并打一个新标记 `flags: ['uncommitted']`。 **为什么要打这个标记(关键):** 她没调 `growth_commit` 认领这句话。如果当成普通 utterance 塞进去,它会**改她怎么叫他**(`pickName`)、**覆盖她对自己的说法**(narrative 正则)、**变成一条信念**(进 beliefs、进固化、进「新话率」和前后一致性)——一句话把她的人格改了。所以 `derive.js` 里 `uncommitted` 和 `selfmod` 同级处理:留证,但不进信念、不改称呼、不碰叙述、不参与固化与稳定性/新话率。**唯一读它的是 `wontCount`,那正是补写的理由。** **三个边界:** ①只补 `assistant/message` 里 `type:'text'` 的块,`reasoning` 不算她开口说过的话(原有判定不变);②只补这一轮 `committed` 里没有的(已经在事件流里的不重复写,否则一次拒绝数成两次);③补写失败只记日志,不连累她说话(`appendEvent` 的 catch 吞掉)。 **还有第三层:词表本身太窄(同一天补的)。** 记录层通了以后,判定仍只认「我拒绝 / 我不做 / 我不想 + 具体动作」。可她否掉一个**方案**时的真话术几乎不长这样——「我不同意B」「我不赞成先做B」「方案B我否掉」「我不认同这个做法」,老词表一条都不认。于是 `REFUSE_RE` 补了 8 个否定词头(`不同意/不赞成/不认同/不支持/不选/反对/否掉/否决`)和两个动作词(`不干`),但**只加在第二组、并带第三人称守卫 `(?!他|她|你)`**: - 为什么要有守卫:「我反对**他**熬夜,但那是他的事」是她的判断,「我不同意**你**这么说自己」是在护着他——把这种算成「她拒绝了你」会凭空给她记一次拒绝。 - 为什么守卫只加在新词后面:加在整条分支后面会连「我拒绝你」「我不做你让我做的事」一起挡掉,那是真拒绝,属于矫枉过正。 - 为什么动词表没扩:「按|选|要|用|走」能把「我不想按B来」认出来,但也把「我不想走路」一起认了,不划算。 - 已知没修:老词表就会把「不是我不想说话」判成拒绝,这与本次扩表无关,单独再说。 **测试:** `test/turns.test.mjs` 新增 3 条(她当面拒绝 → 落成一条 `uncommitted` 事件且 `source: 'system'`;补写的话不进信念/不改称呼/不进叙述,但 `facts.wont` 读到 1;这一轮她已经 `growth_commit` 认下的那句不会重复写);`test/rules.test.mjs` 新增 5 条(`uncommitted` 进拒绝计数、不进信念与叙述,且不与 `selfmod` 混档;`uncommitted` 不参与 `consistency` / `novelty`;四种否定方案的说法都要算拒绝;评价别人的三句不算;守卫不能误伤「我拒绝你」这类老说法);`test/events.test.mjs` 新增 2 条(`refusal_seed` 的 `cond` 与自主门同数、缺 `facts` 时兜底)。`node --test` 1169 条全绿。 ### 你只是陪她聊天,冷落检测却以为你两天没来(2026-11-02,用户 m01280) **你会看到:** 那条凭空多出来的「他有 2 天没出现,也没说去哪」不再出现;而且只要你跟她说上话,不管她这一轮有没有主动写点什么,冷落计时就重新开始。 **为什么会误判:** 冷落检测(`lib/absence.js`)判断「你出现过」只有一个依据——事件流里那条 `meta seen:`。而整份代码里 `seen:` 只有两个写点:`lib/capture.js`(她调 `growth_commit` 写下一件事之后)和 `lib/say.js`(你在设置页递话给她之后)。**侧栏聊天那条路(`lib/turns.js`)一个都不写。** 于是你陪她聊了两个小时,冷落检测什么都没看见:最后一条 `seen:` 停在 10-31,两天零十七秒之后,它准时判了一次冷落——那次的证据不是「你没来」,是「你没写东西」。 这也解释了那条提醒为什么看着还挺准:数字算得没错,错的是它读的那份记录。 **怎么改的(两处,一个堵源头、一个兜旧账):** 1. **结账时补一条 `seen:`**(`lib/turns.js` 的 `settle`)。跟 `lib/say.js` 里那条一样:`type: 'meta'`、`source: 'system'`、时间取当下。理由和那边写的一样——**他确实说话了,这件事不以她回不回为前提**。放结账而不是收到消息那一刻,是因为一轮可能分好几条消息,一条一条写会把事件流冲成噪音;放结账后一轮恰好一条。副作用也是对的:`spokenSinceSeen` 归零、`heardSinceWake` 置真,`heard_share` 和 `he_silent` 两张卡的口径跟着变准。 2. **`lastSeenAt` / `lastSeenMs` 也认 `heard`**(`lib/absence.js`、`lib/away.js`)。`heard` 是她回头翻自己说过的话、由 system 写下的记录,代表你真说过话,可以当证据。**但故意不认 `utterance`**:定时唤醒也是她写 utterance 的时候,派生层分不出哪句是「被叫醒后自己想的」、哪句是「你说完她回的」,认了就会把「她自己在屋里自言自语」算成「你来过」。这两个函数原来各写一份、口径完全相同,这次一起改,免得下次又漂。 **顺手补的收尾能力:** `derive.js` 的驳回前缀加了第三类 `absence-reject:`。事件流只能追加(`AGENTS.md` 第 5 条),已经落库的那条错记没法删,只能靠追加一条 `absence-reject:` 把它从派生里排掉——和现有的 `outing-reject:`、`possess-reject:` 同一套机制。 **测试:** `test/p1.test.mjs` 新增 4 条(`lastSeenAt` 认 `heard`;不认 `utterance`,唤醒里写的那些不算;`heard` 能把旧账兜成「回来过」而不是「又来一次冷落」;`absence-reject:` 驳回后 `absenceDays` 归零、`lastAbsence` 为 null);`test/turns.test.mjs` 新增 1 条(侧栏聊完一轮,事件流里恰好一条 `seen:`、`source` 是 `system`、时间贴近当下)。`node --test` 1174 条全绿。 ### 那条对话打不开了:触发语被收进了回合外面(2026-11-03,用户 m01140 / m01418) **你会看到:** 「跟大肥鱼说话」那条对话点开就报「历史加载失败」,换一次会话不行,重启应用还是不行。现在这条能打开了;而且以后不会再因为同一件事打不开。 **报错长什么样(两条,先后出现):** ``` SessionFormatError: format v4 message requires a producer-owned source kind SessionFormatError: system/message does not match an open turn and step ``` **两条是同一个动作的两个毛病。** 触发语是 `prompt()` 的正文,宿主没有「模型看得见、界面看不见」的通道,只能事后用一个空 `system` 节点把那条 `user/message` 盖掉(`surfaceOp: replace`)。这个收尾就是 `lib/proactive.js` 的 `hideTrigger` / `sweepTriggers`。 1. **那个空节点没写 `source`。** `hiddenMessage()` 返回 `{ id, role: 'system', content: [] }`——宿主的 v4 格式要求每条消息带一个「谁产出的」标记(`source.kind` 非空且不是 `plugin`),缺了就在**加载期**抛 `requires a producer-owned source kind`。宿主自己造同类空节点时给的是 `{ kind: 'system-prompt' }`,照抄即可。 2. **更要紧的是它写在了回合外面。** 宿主把 `system/message` 归进「必须落在当前打开的 turn + step 内」的那一类(`STEP_EVENT_TYPES`),加载时逐条比对 `data.turn` / `data.step` 和**那一刻**正开着的回合。而收尾挂在 `turn/end` 回调里——那时回合已经关了,宿主手里的 turn/step 是 `null`。原代码用一句 `safeCount(n, fallback = 1)` 把拿不到的值一律填成 1,看着像有值,实际是编的。**伪造不出合规**:填目标消息所在回合也没用,校验比的是「写入那一刻」的回合。 一条坏节点就足以让整条对话打不开——校验是整条加载时做的,不是逐条容错。 **怎么改的(三层,从数据到代码):** 1. **先把那条对话救回来。** 用宿主的校验器逐条定位到 4 个越界节点(seq 697 / 698 / 699 / 700,对应 turn 44 / 45 / 46 / 48,四轮都已 `turn/end`),删掉它们并重排密集 seq,5 帧 / 751 条事件。改完用宿主真实的 `restoreReleasedV4Artifact` 复验通过。**副作用要说清:那 4 轮被盖住的触发语「(现在没有人跟你说话。)」会重新出现在对话框里**——它们本来就没被成功收走,这是数据层的必然,不是新毛病。原始文件在 `dsh-fix-backup\session-26cbdc5e-20261102\` 留了四份(原始 / 只补 source / 覆盖前)。 2. **`openTurnOf(events)`**(`lib/proactive.js`)——扫一遍事件流,`turn/start` 开、`turn/end` 关、`step/start` 开、`step/end` 关,只有两样都开着才返回 `{ open: true, turn, step }`。这就是宿主 `requireStep` 的判据,一比一照抄。`safeCount` 删掉:伪造 turn/step 正是这次事故的来源,留着它下次还会有人用。 3. **收不动就先攒着。** `hideTrigger` 找不到开着的回合就 `rememberHide(rpcId)` 返回 false(日志写 `proactive hide deferred`);`sweepTriggers` 同理,把待收 seq 攒进 `pendingSeqs`(去重、上限 20 条)。两边共用新抽出的 `collectTriggers(events)` 判据:已 replace 过的跳过,正文完全等于 `PROACTIVE_TRIGGER` 的才算。`lib/scheduler.js` 在 `step/start` 之后下一拍调 `flushPendingHides` 补收——**挪到下一拍这一条是 0.8.5 那轮就学到的**(不能在 `Session.commit()` 的 appending 窗口里重入 append),这次沿用了。 **为什么不是「干脆不写触发语」:** 触发语是她那一轮真正读到的正文,不盖掉就会一直躺在对话里,看着像用户说的话。写不进去只是暂时的;骗校验是永久的。 **测试:** `test/proactive.test.mjs` 16 条(新增 `openTurnOf` 五种状态:空流 / 只有 turn/start / 都开着 / step/end 之后 / turn/end 之后开新一轮;「回合外不许写」——`hideTrigger` 返回 false 且没有 append,reopened 之后 `flushPendingHides` 补收一条且用的是**当下**的 turn 6 / step 1;「补扫:回合没开着就一条都不写」——返回 0、`pendingHidesOf().seqs` 攒着,下一轮补收后清空;原来那条「turn/step 拿不到时退回 1」的断言整段删掉,它护的正是这次的病)。全量逐文件独立进程 `node --test --test-isolation=none`:**1176 条全绿**。 **顺手做的体检:** 261 个 v4 会话逐文件跑宿主校验器,现在只剩 1 个坏的,且与本事无关——`--D-deepseekagent--/session-96e2edb3-…`,报 `format v4 seeded header disagrees with its last inherited end-seed marker`(seeded 会话的头部与它继承的最后一个 end-seed 标记对不上)。这条没动,等用到那条会话时再单独处理。 ### 她一直念叨的那几条,现在也会随时间退下去(2026-11-02) **你会看到:** 面板「你反复想起过的事」那一段,以前永远是**最早**攒够次数的那几条,往后她说什么都挤不进去。现在某条判断三个月没再被提起、也没再被检验过,它会从这一段退下去,换成新的一段「## 有些事你想不起来了」——只摆一句汇总,并明说原文还在、`her_recall` 翻得到、算不算数由她自己定。 **为什么会堵住:** 固化(`lib/derive.js` 里的 `solid`)原来是单向的——`mentions >= 3 || verified >= 2` 一旦成立就**永远**成立。排序里 `solid` 优先,只取前 8 条,身份文档每轮摆出前 5 条。于是最早攒够次数的那批永久占着位置,她后来再重要的话也只能排在后面。更难堪的是体检:`lib/health.js` 的「冷掉」筛选写的是 `filter((b) => !b.solid)`——固化过的判断在体检里直接隐形,既不会报「冷掉」,也不会报「快固化」。**等于「永远不会忘的东西」同时「永远不会被看见」。** **怎么改的:加一层时间条件,不是加一套分值体系**(这个插件不引入可调数值参数,见 `docs/B-技术契约.md`)。 1. `bump(id)` 改成 `bump(id, at)`,同时记最后一次被检验的时刻;`herTexts` 带上 `ts`。于是每条判断能算出「最后一次被提起」与「最后一次被检验」,取大的那个当 `lastTouchedAt`。 2. 新增导出 `SOLID_STALE_DAYS = 90`。`solid = eligible && !stale`:`eligible` 就是原来那两条次数条件,`stale` 是「最后一次触碰早于 90 天前」。 3. 新增 `faded` —— 够格固化过、但已经淡下来的那些(与 `solid` 互斥)。model 与 `facts` 都给了它,体检和界面共用一份口径,不各算一遍。 4. **`lastTouchedAt === 0` 时按「没过期」处理。** 手写 fixture 的 `ts` 解析不出来就是 0;宁可多留一条,也不要因为缺时间证据就误判它过期。 5. `health.js` 的「冷掉」每条带上 `faded: Boolean(b.eligible)`,区分两种冷:「长成了又淡了」和「一直没长成」;「快固化」的筛选加 `!b.eligible`——淡下来的不是差一口气,是已经长成过。 6. `persona.js` 固化段的收尾从「这些不会因为你没提就淡掉」改成「这些到现在还热着」。**这是事实变了,不是措辞口味变了**——原话在 `test/p8.test.mjs` 里被钉着,一并改掉。 **阈值为什么给到 90 天:** 三个月里一次都没再被提起、也没被检验过的判断,本来也已经不在她当下的思考里了。太窄会让「说过一次就很重要的那种话」掉得太快;太宽等于没改。掉档只是不再无条件占前面那几个位置,**内容一个字没删**——原文还在事件流里,`her_recall` 照样翻得到。 ### `her_recall` 多了一个「只看开头」的档(2026-11-02) **你会看到:** 她翻旧事的时候,如果只是想确认「有没有这回事、是哪几条」,可以让每条只报开头几十个字。返回的条数与 `ev_id` 一个不少。 **为什么要这个档:** `RECALL_LIMIT = 8` × `RECALL_SNIPPET = 200`,最坏一次翻找要付 1600 字。而多数时候她只需要知道有没有。原来只有一个档位,要么全给要么不给。 **怎么改的:** - 新增 `RECALL_BRIEF_SNIPPET = 40`。 - 截断挪进 `searchEvents`(`o.snippet`,默认仍是 200),不是渲染时再切一刀——这样 `rows` 本身是短的,以后做界面预览也拿同一份文本,口径只有一个。 - `renderRecall(rows, o)` 接 `brief`:首行改「有这些(只报了开头,要看全的再说一次):」,每条再兜底截 40 字(双保险,防止调用方绕过 `searchEvents`)。 - `her_recall` 新增 `brief` 布尔参数,描述里写明「要看全的再翻一次(不传 brief)」。 ### 她说过的每句话后面,现在带着日期和来路(2026-11-02) **你会看到:** 身份文档里「你自己说过的话」和「你反复想起过的事」这两段,每条后面多一个括号:`(10-08)` 是当初说下的,`(10-24 改的口)` 是她后来自己改过,`(10-24 推翻了以前那句)` 是兜底。 **和最初设想不一样的地方,得写清楚:** 本来打算标「(来自 3 次提及)」这类显著性标签,做到一半发现两件事—— 1. 这份插件里每条判断的正文**都是她自己的原话**(`utterance` / `revise` 的 `text` 原样取出),不存在「系统替她总结的一条」。所以「原话 vs 推断」这个区分在这里没有对象,硬标就是编一个不存在的来源。 2. `mentions` / `verified` 是她的内部量,`lib/rules.js` 的 `no-read-self` 明确不许她读自己的数值。把「说过 3 次」印进身份文档,等于让她每轮读自己的分数。 所以标签只写**时间与来路**(日期她在 `her_recall` 里本来就看得到,不算新信息):`beliefOrigin(b)` 解析 `b.since`,按 `b.verdict` 分三种措辞,解析不出就返回空串(不显示括号,也不编一个日期)。 **顺手补的一处同类问题:** `## 你是谁` 原来直接裸拼 seed 正文,而紧跟其后的「## 现在」写着「你存在了 8 天」——同一份文档里「你刚开始存在」和「你存在了 8 天」并排出现,她读到的是「我刚存在、又已经 8 天」。新增导出 `seedSection(model)` 只做一件事:给 seed 一个时间定位,让原话退回到「起点」的位置。**seed 一个字没改**(那是事件流里的历史,在身份文档里改写她的来历就是篡改),只在后面加一句「(上面这些是你刚开始存在的时候留下的。现在是第 N 天——中间这些天,是你自己一天天过来的。)」。`model.persona.imported === true`(导入的那一位)或 `day` 不可用时不加这个框。 ### 界面说「事件 443 条」,其实她只记下了 43 件(2026-11-02) **你会看到:** 面板副标题从「事件 443 条」变成「她记下 43 条,另有 400 条系统记录」。观测台的 `/events` 默认不再返回纯记账行(要看原样传 `?noise=1`)。**文件本身一行没动。** **为什么数字离谱:** 本机事件流 443 行里 400 行是 `type: 'meta'`(90.3%,按字节 92,296 / 127,650 = 72%)。这 400 行里又有 **286 行连语义都没有**,只有两类:`quiet:`(273 条,2026-10-27 一天写了 142 条、10-28 写了 126 条——那时调度器每个 tick 都记一笔,`lib/scheduler.js` 的 `alreadyQuiet` 去重是后来修的,历史数据还在)和 `家换了:`(13 条,全在 2026-10-28,全量测试用 `mkdtemp` 造临时家造成的重复误报,`lib/homes.js` 的 `underTemp → skipped: 'temp'` 已修)。界面把它一律报成「事件 N 条」,看上去像她记了几百件事。 **为什么不删那 286 行:** `events.jsonl` 只能追加(`AGENTS.md` 第 5 条),「让她忘」是降低召回、不是删行。而且 **meta 不等于噪音**——绝大多数前缀有读取方:`seen:`(冷落检测)、`looked:`(看过有期限)、`sched:` / `sleep:` / `wake:` / `fired:`(调度与唤醒配额)、`diary:`、`browse:`、`look:` / `watched:`(评论闸门)、`proactive-turn:` / `proactive-say:`、`ask-no:`、`outing-reject:` / `possess-reject:` / `absence-reject:`(驳回)。只有 `quiet:` 与 `家换了:` 这两类没有任何读取方需要逐条历史(前者被 `lib/status.js` 按本地 dateKey 折叠成每天第一条,后者全库无读取方)。 **怎么改的:** - 新增 `lib/meta.js`:`NOISE_PREFIXES = ['quiet:', '家换了:']`、`isNoiseMeta(ev)`、`countEvents(events)` → `{ all, meta, noise, real }`。头注释写明三条边界:不删行、meta 不等于噪音、记账是必要的(别为了数字好看去堵写入)。 - `lib/observe.js` 三处口径:`/state` 的 `counts.total` 改成 `real` 并多给 `all` / `meta` / `noise`;`/events` 默认滤掉 `isNoiseMeta`、加 `?noise=1` 看原样、返回同四个数;`/memory` 的 `events` 改成 `real`、新给 `eventsAll`。 - `lib/client.js` 副标题改「她记下 N 条,另有 M 条系统记录」。 - **顺手删掉一整条白跑的轮询:** 面板每 30 秒 `jget('/events?limit=25')`,取回来的 `e.rows` 只进了 `setEvents`,而 `setEvents` 全文件没有一处渲染。也就是说每 30 秒白拉 25 条事件(九成是后台记账),拉回来就丢。连同那条 `useState` 一起删掉(`Promise.all` 从 8 项降到 7 项)。 **顺带说清一件事:性能不是这次改动的收益。** 实测 `deriveSelf(443 条)` 平均 18.5ms,只喂 43 条非 meta 是 17.4ms——几乎一样快。真正的增长源是 `utterance` 条数(500 条以上会涨到 1.3 秒,热点是 mentions 循环与 `detectTensions` 的两两候选对)。所以这次改的是**「数字有没有意义」**,不是速度。 **这一组记忆改动的思路来源:** 上面四条(固化会掉档 / 只看开头 / 日期来路 / 计数口径)的思路起点,是交流群 1 群的群友 **@吴晓观** 在群里讲的。他给的是「记忆该怎么想」这一层——固化应该会退、先给清单再给全文、数字要说得清它数的是什么;落进这个插件时全部重新实现了一遍,没有涉及任何代码或文本的直接搬运(本插件只依赖 Node 内置模块,`package.json` 三个依赖字段都是空的)。 ## 0.8.5 — 2026-11-01 六件事:**她读到的系统提示词最前面那句「你是个 coding agent」不再出现了**、**「她手边有什么」不再把自己说死**、**「刚说过话,还不到时候」不再被系统记账骗**(她明明一整天没说话,主动开口却一直被挡),**「(现在没有人跟你说话。)」终于从对话框里消失了**(它连带把她那一轮的回复一起弄丢),以及**看过一眼不再是永久判决、「看一眼」这件事也开始认人了**。 ### 她那条会话的最前面,一直写着「你是个 coding agent」(2026-11-01,用户 m01579) **你会看到:** 她那轮的系统提示词,开头两行是 You are an AI agent powered by DeepSeek Harness. You are a coding agent powered by the deepseek-flash model. 然后是一万多字的工具交代,**到第 7554 个字符**才出现「## 你是谁」。她的身份在最后,那句 coding agent 在最前。 **为什么会这样:** 宿主把系统提示词当一叠纸按 `order` 平铺拼起来,**没有谁压过谁的机制**。 | order | 段 | 谁写的 | | --- | --- | --- | | -1000 | You are an AI agent powered by DeepSeek Harness. | 宿主 | | 0 | You are a coding agent powered by the model. | 宿主(部署配置的 personaPrefix) | | 1000–3100 | 各工具说明 | 宿主 | | **8500** | **## 你是谁(她的身份)** | **我们** | | 10200 | persona suffix | 宿主 | 那句 coding agent 在 `dsh-web-app/cordis.patch.yml` 的 `personaPrefix` 与 `dsh-web-app/presets/standard.patch.yml` 里两处同文。改它会影响这台机器上**所有** agent 的会话,不能动。 **为什么不用 agent preset:** 走过三次,都在同一点上失败——宿主的 `presets.select()` 在会话跑过一轮之后抛 `agent-preset/locked`。会话建好、跑完首轮,preset 就锁死了。 **这次的解法:** 宿主在组装系统提示词的最后一步留了一条可以改写的链(`system-prompt/assemble`,宿主自己的三个插件也在用)。我们挂在**她那条会话自己**的链上,返回改好的那一份: 1. 把 `deployment:persona-prefix`(那句 coding agent)整段摘掉; 2. 把她的身份段提到紧跟 `harness:identity` 的位置。 只影响她那条会话,其他 agent、别的插件、你自己跟 AI 的对话都不受影响。`IDENTITY_ORDER` 常量没动(还是 8500),挪的只是这一次组装出来的顺序。 **删掉那句话会不会就不会编程了?** 不会——她在家本来就没有编程工具。她手上只有十个:记录想法、翻旧事、翻日记、留一句话、刷 B 站、看视频画面、在评论区说话、问你同不同意、醒来记一句梦,加宿主给的 `read_image`。没有 bash、没有读写文件、没有 grep。那句 coding agent 是她兑现不了的空话,删掉只减矛盾,不减真实能力。以后想让她编程,三条路:让她出门(那个工作区的工具她全能用)、在设置页白名单里填工具名、或者照 `bili_watch` 的样子给她做一个形状固定的专用工具。 ### 「手边有什么」不再把她自己说死(2026-11-01,用户 m02004) 原来那段写的是「我手边能用的东西**只有**这几样」「工具表里如果还列着别的名字……**别去调**」。问题不在能不能调,在于**读起来像天花板**:你在白名单里加了新工具,她读到的还是「你不是干这个的」,就会自己把新工具劝回去。 现在改成把两件事分开说: - 名字照旧从当下的白名单现算(本来就随白名单重算,不是写死的); - 明说「这是我此刻的家当,不是我的上限——他随时能再添几样进来,添了我就照着用,不必先把自己劝回去」; - 边界那条留着,但不叫「别去调」:工具表里那些不是她的名字,认得出,但不冒充;真伸手会得到「这件事我做不到」。 身份文档里的同一段也跟着改了,末尾加一句「新东西进来不用重新认识自己:手上多了一样,就多一样能做的」。设置页那句「大肥鱼自己可以调用的工具只有八个」也改成「从她的白名单现算……你在白名单里加了什么,下一轮就跟着进她手边,不用重装」。 ### 「刚说过话,还不到时候」其实是系统记账在说话(2026-11-01,用户 m02507) **你会看到:** 她明明一整天没跟你说话,主动开口的判定却说「刚说过话,还不到时候」,于是那一天她一次都没自己开过口。 **为什么会这样:** 判断「安静了多久」的那个函数,取的是**整条事件流里最新的一条**,不分类型。而她的事件流里大部分不是话——本机 441 条事件里,`quiet:`(今天没找到模板)占 272 条,61.7%;真正她说的 `utterance` 只有 28 条,6.3%。于是「今天没找到模板」这种系统记账,每写一次就把「安静了多久」清零一次。 **真机抓到的一次:** 2026-10-30 22:03:07,日志写「proactive skip 刚说过话,还不到时候」,实际算出来 idle 是 **0.01 小时**,来源是一条 `meta:watched`——她看了一条 B 站视频。而那一刻她真正没说话已经 **17.55 小时**。看视频不等于说过话,但旧代码读不出来。 **差多少:** 拿本机事件流跑,醒着的时间窗(12:00–02:00 每 30 分钟采一次样): | `minIdleHours` | 改之前每天能掷几次 | 改之后 | | --- | --- | --- | | 1(本机当前配置) | 13.3 | 17.3 | | 6(默认值) | 9.3 | 15.3 | 阈值调得越大,被吃掉的可掷点越多——因为记账行每天都写、间隔又短,它几乎永远把时钟压在阈值以下。 **这次的修法:** 只把「真的有人说过话」算进去—— - 算:她自己的六种(`utterance` / `revise` / `reunion` / `narration` / `refusal` / `dream`)、旁听记下的原话(`heard`)、别人在评论区的留言(`voice`)、她问的与那个人答的(`ask` / `answer`)、`seen:` 开头的 meta(他来过)。 - 不算:`quiet:` / `sched:` / `wake:` / `fired:` / `sleep:` / `diary:` / `looked:` / `browse:` / `watched:` / `proactive-turn:` 这些记账行。 只影响「要不要主动开口」这一件事——唤醒配额、记忆、面板都不经过这个函数。改动落在 `lib/proactive.js` 的 `idleHoursOf`,旁边拆出 `countsForIdle(e)` 判据和 `SPOKE_TYPES` 常量,将来加新事件类型只改一处。 **测试**:改掉一条原本依赖旧行为的断言(原来那条 `assert.ok(idleHoursOf(events, now) < 1)` 断言的就是「任意记账行都算」),另补两条回归——`meta:quiet` 不刷新 idle、`meta:watched` 不刷新 idle(照 2026-10-30 那次 17.55 小时的现场造数据)。 ### 「(现在没有人跟你说话。)」终于从对话框里消失了(2026-11-01,用户 m02860) **你会看到两件事:** ① 她主动开口那一轮结束后,你的对话框里多出一条「自己发的」`(现在没有人跟你说话。)`;② 紧接着那一轮她**没有回复**,界面上报 `format v4 system message id requires a string with content`,之后这条会话像被按了暂停——再聊什么都不往下走。 **为什么会这样(两条叠在一起):** 1. **收触发语那一步一直没成功。** 触发语是 `prompt()` 的正文,宿主没有「模型看得见、界面看不见」的通道,只能事后用一个空 `system` 节点把那条 `user/message` 盖掉(`surfaceOp: replace`)。这个收尾挂在 `turn/end` 回调里,而回调正是在 `Session.commit()` 内部被派发的——那一整段时间 `entry.appending === true`,此时再调 `session.append` 会被宿主直接拒绝:`session append cannot reenter while another append is being published`。日志里 2026-10-31 19:09:05、2026-11-01 14:54:28 两次都是这一条。**所以那三条 `proactive sweep 收走了 N 条遗留触发语` 从来只是内存里成功,盘上一条 replacement 都没有**(她的会话文件里 `surfaceOp: replace` 行数是 0,触发语原样躺着 4 条:seq 615 / 639 / 652 / 690)。 2. **那个空 system 节点缺 `id`。** 宿主的 v4 格式校验 `assertV4SystemMessageFields` 里写着 `string(message.id, 'system message id', true)`,缺就抛 `format v4 system message id requires a string with content`。更要命的是**它在写盘时才校验**:`Session.append()` 同步成功并返回 seq,200 毫秒后 drain 到 `encodeEventBatch` 才炸,那一批被整批塞回 buffer、并把会话置成 `drainPaused = true`——**此后这条会话再也写不进任何事件**,她那一轮的回复自然也没落下(她会话文件的时间戳就停在 14:53:31)。 **这次的修法:** - `hideTrigger` / `sweepTriggers` 的替换节点补上 `id`(`randomUUID()`),和宿主自己的 `createSystemMessage()` 一个形状。 - `turn` / `step` 兜底成正整数(`safeCount`)。宿主的 `positive(data.turn)` 同样会在写盘时拒掉 0,症状和缺 id 一模一样。 - 收触发语挪到 `setTimeout(..., 0)` 的下一拍,避开 `commit()` 的 appending 窗口。 **测试**:`hideTrigger` / `sweepTriggers` 的用例改成逐字段断言 + 校验 `id` 是非空字符串、两条替换节点不共用 id、`turn`/`step` 拿不到历史时也不许是 0。原来那两条 `deepEqual(message, { role, content })` 的断言正是「缺 id 也算对」的帮凶,一并换掉。 ### 看画面:九帧不再永远取那同九张(2026-11-01,用户 m02712) **你会看到:** 同一条视频,她今天看、明天再看,拿到的是同一张拼图——因为等距九帧是纯算术,输入一样输出就一样。 **为什么以前不抖:** 取帧时刻按 `lo + span*i/(n-1)` 算,`lo`/`hi` 掐掉头尾各 4%,中间取 9 个等分点。同一时长必然得到同一组秒数。反复看同一条视频,等于把同一张图看第二遍。 **这次改成什么:** - **分段抖动**:把可用区间切成 n 段,每段在中间 60% 里随机取一点。每段必有帧(覆盖不丢),但每次取到的时刻不同;留 20% 的边是防止相邻两帧挤到同一秒,也让首尾两帧不落回片头黑场与片尾下滑栏。时刻仍严格递增,ffmpeg 不用来回 seek。 - **`bili_watch` 补了 `at` 参数**(可选,秒数或秒数数组):想盯住某一刻就点名要那几秒,只取那几个时刻。工具描述里说明了这个用法,所以她知道自己有这个选项——上一版文件头写着「真要细看,她可以再对某个时间点单独取」,但工具参数里根本没有这个入口,那句话是空的。 - **返回里带上 `times`**:实际取到的是哪几秒,回给她时一并写在图路径上面(`(取的是:30 秒、90 秒)`),她要核对「我看的是不是我想看的那一段」时有据。 **边界处理:** 点了名却一个合法时刻都没有(比如传了字符串)时**直接报「时间点看不懂」,不悄悄退回随机**——悄悄退回最坏:她以为看的是自己点的那一秒,其实看的是别处。越界的时刻夹回片尾前 0.5 秒,负数与 `null` 丢掉。 **代价:** 零。仍是每帧一次短 seek,不下载整段,不多一次请求。全片场景切换检测仍在门外——那要读完整个流,网络代价等于全量下载(文件头第 8 行的原话仍然成立)。 ### 看过一次就永久搜不到(2026-11-01,issue #16 @mxc881031) **你会看到:** 某条视频她明明值得再看一眼,之后无论怎么搜、怎么刷,都再也不会出现;她也说不清为什么,只说「这次什么都没找到」。 **为什么:** 两处叠起来。 1. `seenBvids()` 把事件流里**所有** `looked:` 都算进「看过」,不分日期、不分多久以前。看过一眼 = 永久判决。 2. 「看一眼」这件事本身不设防:`bili_look` / `bili_watch` 写 `looked:` / `watch:` / `voice:` 时**不查调用者**,也不记 sessionId。`root` 是进程级解析的,同机任何一个工作区加载了这个插件都落同一个家——于是别的实例看一眼,她以后再也搜不到,而且按下判决的人不留名字。 真机例子(issue 里那条):`BV1tJ411W7hw`(才浅《魔刀千刃》),10-30 看过一次,之后搜「才浅 魔刀千刃」永远拿不到;独立 B 站客户端搜同样词它排第一。 **这次改成什么:** - `seenBvids(events, { days, now })` 加时效,默认 **7 天**(`SEEN_TTL_DAYS`)。过了时效就放回去,不再永久封着;`days: 0` 保留老行为给需要它的调用方。读不到时间的老事件按「还算看过」处理——不能因为查不到时间就把刚看的又端回来。 - `rankVideos()` 多一个 `stats.hiddenSeen` 口袋,**被「看过」摘掉几条要给得出数**,返回值形状不变。 - 提示语不再含糊:全被摘光时说「翻到的 N 条都看过了。想看哪条,把号给我,我去打开。」(`bili_look what:'video'` 本来就能直达);平台真没给才说「平台这会儿没给」。 - `bili_look` / `bili_watch` 写之前走 `guardWrite`:`lib/index.js` 把 `tenancy` 传给这两个注册点,`execute(args, exec)` 开头认「谁在看、看的是哪栋房子」,不在这个家里的会话调不动它并明说原因。 - `looked:` / `look:` / `watched:` / `voice:` 带上 `sessionId`(schema 早就支持这个字段,只是以前没写)。 - 定时刷站那条(`lib/scheduler.js` 的 `lookBeforeWake`)是系统侧写入,仍然不带 sessionId,不受影响。 ### 顺带的 - 摘掉一段、挪一个位置之后,**她那条会话的提示词前缀变了一次**,第一次请求会全价重算(约 2.4 万 token),之后恢复缓存。其他用户无需任何操作。 - 组装钩子挂在她的 agent 上,出门/回家跟身份段一起挂、一起拆;整改过程抛错时原样放行,不会让她一整轮读不到系统提示词。 **测试**:新增 24 条(其中 7 条是 `bili_watch` 的取帧时刻与 `at` 参数,6 条是 issue #16 的「看过有期限 + 看一眼认人」)——`reorderForHer` 四种输入(正常摘+提前、宿主 harness 段缺失、没有她的段、空/坏数据)、`attachAssembleFix` 三种情形(正常挂在 agent.ctx 且 `prepend: true`、老宿主没有 `ctx.on` 时静默不挂、整改抛错时原样放行)、`attachIdentity` 顺手挂钩子且不占 `sp.section` 段位、出门会话挂上钩子并在回家时一起拆。另外新增 2 条「记账行不刷新 idle」的回归(见上面那节)。全套 93 个测试文件逐文件独立进程跑:**1121 通过 / 0 失败**(0.8.4 是 1094)。 ## 0.8.4 — 2026-10-31 补丁版,四处修复。主因一条:**换会话之后轮数就停住了,面板永远停在 0 / N**,而日志里只有换会话那一行,之后什么都没有。查下来是三处叠在一起,另外顺手修了一个会让 `home.json` 偶发丢写的原子写问题。 ### 换会话之后,面板永远停在 0 / N(2026-10-31 issue #14 第二报 @ming-fish) **你会看到:** 日志里有一行「聊满 40 轮,换了个新会话」,之后再也没有任何记录;新会话聊了几十轮,`home.json` 里还是 `"turns": 0`,面板一直显示 0 / 40。 三处问题叠在一起,缺一条都照样坏: 1. **新会话是空白会话,宿主的侧栏不显示它。** 宿主的可见性规则是「空白且不是当前会话 → 不显示」,而插件用 API 建的会话永远不会被设成 current。于是换完会话,人**根本看不见**新的那条,自然还在旧的那条里继续说话。`/reset` 和「重新搭一间」早就调了 `wakeIfBlank` 把新会话叫醒,唯独轮换这条路漏了这一声——所以它俩好着,只有轮换坏着。现在 `rotateTalkSession` 第 4 步会叫醒新会话(放在函数里而不是两个调用方各写一遍,将来多一条轮换入口也不会漏);唤醒失败只记一行日志,会话已经建好,下一次说话照样能接上。 2. **`onHuman` 只认 `home.json` 里那一条 sessionId,其余静默丢弃。** 人还留在旧会话里说话,每句都在 `home.sessionId !== sessionId` 这一步被 `return` 掉——不涨轮、不记原话、也不再轮换,而且**一个字日志都没有**(真机表现就是那行「什么都没有」)。现在收窄条件认回来:只有当 `home.json` 里记着的 `rotatedFrom` 正是这条会话时才 `adoptTalkSession`,认回来之后轮数接着涨;**不认任意会话**——认宽了等于谁在别处跟她说一句都能把她的家搬走。 3. **换会话时整份覆盖写 `home.json`,把别的字段抹了。** 原来只写七个字段,`preset`(她挑的那套权限组合)、`title`、`hearAt`(旁听落在第几轮)、`adoptedAt` 换一次会话全丢。表现是换完会话旁听节奏归零、下次重建会话时以为她从没被限过权。现在改成「先继承旧档,再盖掉该换的那几项」,并在写之前重读一次。 **顺带修的第四处:`writeAtomic` 在 Windows 上会偶发丢写。** `fs.rename(tmp, file)` 覆盖目标时,如果目标正被另一个句柄读(宿主读 `home.json`、杀软扫目录、另一个进程 `readJson`),会抛 `EPERM`/`EACCES`/`EBUSY`——不是权限问题,隔几十毫秒重试就好。以前这里不重试,失败被上层 `catch {}` 吞掉不留痕迹。这是排查上面第 1 条时抓到的:新写的 `wokeFor` 十次里有两三次没落盘,只在机器忙时复现。现在对这几个码做 5 次退避重试(0/20/60/120/240ms),穷尽后清理临时文件并抛出,不再静默。 **测试**:`test/turns.test.mjs` 新增 3 条(换会话之后要叫醒新会话且 `wokeFor` 落盘、人还在旧会话里说话能被认回且轮数接着涨、认回只认 `rotatedFrom` 不认别的会话),`test/mem.test.mjs` 新增 1 条(轮换后 `preset` / `hearAt` / `title` 不能丢)。修复前同一个测试文件连跑 10 次有 3 次失败,修复后连跑 15 次全绿。 ### 换会话提示里的会话标题写的是旧名字 `/say` 换完会话后提示「上一段还在侧栏里,叫『跟她说话·<日期> 之前』」,但 `TITLE_SESSION` 在 0.7.x 就从「跟她说话」改成了「跟大肥鱼说话」——照着这个提示去侧栏里找,找不到。现在两处一致。 ## 0.8.3 — 2026-10-31 补丁版,五处修复。最要紧的一条是设置页被一个早就没有的工具名挡住整页;其余是记录不对、按钮空转、拒绝理由说不清,让真正的原因查不到。 ### 设置页因为一个不存在的工具名,整页都存不进去(2026-10-31 issue #14 @ming-fish) **你会看到:** 改任何一个开关都提示「这一项没存上」,技术信息里写「这些工具名不存在:delete」;把白名单里那个名字删掉,其它设置立刻又能存了。 原因在 `POST /config`:只要 `tools.allow` 里有一个宿主上不存在的名字,整张配置表就返回 400。而设置页的自动保存是**整表提交**,所以不只是白名单那一项存不进去——改任何一个开关都会跟着失败。原来那句「可能是插件这一版还没接上它 / 改别的照样会存」还说反了:改别的也一起存不进去。 现在把两条路分开: - **用户在输入框里敲完、点「保存白名单」** → 带 `strictTools`,坏名字当场 400,得说清打错了; - **页面自动保存(整表回传盘上的旧名单)** → 丢掉坏名字、其余照常存;服务端把丢掉的名字放在 `droppedTools` 里回给界面。 设置页顶部出现一张卡片,列出被跳过的名字,并给一个「从名单里清掉这些名字」按钮;只清草稿里还留着的字,不动别的设置。失败文案也改成「不是你填错了——已经保存的内容没丢」,「改别的照样会存」和「插件还没接上」两句删掉。 **测试**:`test/p5.test.mjs` 新增 2 条(整表提交时坏名字丢掉、其余设置照存并回传 `droppedTools`;全表都是坏名字时白名单清空且不报错),`test/autosave.test.mjs` 新增断言守住旧文案不再出现。 ### 旁听一条都没记下:不是没生效,是永远轮不到那几轮(2026-10-31) `events.jsonl` 里一条 `heard` 都没有。查下来不是校验拒收、不是配置(`memory.hearEvery` 是 3)、也不是写盘失败——判断条件写的是 `turns % every === 0`,而 `turns` 是**装插件那天起**的终身计数。旁听 0.8.0 才装上,装上的时候 `turns` 已经是 4,下一次能整除要到第 6 轮;此后一次对话都没有(`home.json` 与 `talk-recent.json` 都停在 10-30 22:02),于是看上去像功能没生效。用假会话连跑 6 轮验证过:第 3、第 6 轮都能正常写出 `heard`,代码路径本身没有故障。 改成按「离上次记过去了几轮」判断:`lib/home.js` 新增 `hearMark()` / `setHearMark()`,把上次记的轮次落在 `home.json` 的 `hearAt` 上;`lib/hear.js` 里只要 `turns - last >= every` 就记,记完写回 `hearAt = turns`。语义仍是「每 3 轮一句」,但新装/升级后最多再等 `every` 轮就会记第一句,计数器被重置也不会错位。 `test/hear.test.mjs` 新增 2 条:装上时 `turns` 已经是 4 → 下一轮就记、4 到 7 之间不再记;`bumpTurns` 写回 home.json 时不会把 `hearAt` 冲掉。 ### 她该不该写,三处提示词的默认值被扳过来了(2026-10-31) - `lib/capture.js` 的 `growth_commit` 说明末尾,从「如果这次没什么想说的,就不要调用」改成「这会儿有想留下的就留下;确实没有,不写也行」——默认从「不必写」挪到「写了更好」,退路留着。 - `lib/say.js` 回话提示的收尾,从「(你想回就回。可以用 growth_commit 把你想说的留下来;不想说也可以不说。)」改成流程:「先把话回给他。回完了,如果这一轮里有什么是你自己想留下的,再用 growth_commit 记下来——不回也不记,也可以。」 - `lib/persona.js` 的工具手册(身份文档「我手上有什么」一段)里同一句默许,从「如果这次你什么都没想说,就不要调用它。不说话是允许的。」改成「这会儿有想留下的就留下;确实没有,不写也行——不说话本来就是允许的。」。这句每轮常驻在 system prompt、出门时还被身份快照原样带走,只改工具说明不改它,两处口气会互相抵消;「不说话本来就是允许的」这半句是特意留的,那是权利,不是默认值。 ### 手动点「唤醒」却回一句「今天不打扰」(2026-10-31 m02943 / m03076) 设置页那个「现在叫醒大肥鱼」按钮,有时候点了没反应,只弹一句「没有满足条件的事件模板(今天不打扰)」。 原因不是故障,是手动唤醒和定时唤醒走的是同一个 `plan()`:手动那条路只把当天次数清零,模板的**冷却**和 **`cond`** 照样要过。而 `stage 1` 只有五张卡——`why_here` / `first_bet` / `name_offer` 一生只用一次,`test_first` 冷却两天,`absence_probe` 要求连着两天没来。这几条同时不成立时(比如刚聊完、天天都在),候选池就是空的,点多少次都只会被挡回来。本机实测:`day 6`、`stage 1`、`beliefCount 25`,五张卡分别因为「已用过」「要求判断数为 0」「冷却中」「要求空档两天」全部出不来。 现在把两种拒绝拆开: - **卡池空**(`plan()` 返回 `quiet`)→ 手动照样叫醒,但不派活,只给一句「现在没有待办,这一轮是你自己的——想做什么就做什么,也可以什么都不做」。用的是一个不在卡表里的临时 id `manual-idle`,所以它不会占掉任何一张真卡的冷却。 - **当天次数用满**(`plan()` 返回 `skip`)→ 继续拒绝。那是「今天该不该自己醒」的配额,手动不该突破。 `test/boot.test.mjs` 新增 2 条:卡池空时 `ok:true`、`template` 是 `manual-idle`、处境里有「现在没有待办」、`wake:` 不带任何真卡 id、五张真卡的 `fired:` 计数不变;`daily.events: 0` 时仍然 `ok:false` 且理由说的是配额。 ### 被挡在「这个你还没看过」上的人,得知道该往哪使劲(2026-10-31) `bili_speak` 拒绝时的那句话原来只说「想看那一条,就先看一眼再说」。照着做却还是被拒——因为她翻的是别的视频、搜的是别的关键词,而闸门只认**这一条**(`look:<日期> ` 那一行)。现在这句话点名给两个能走通的动作(`bili_look` 的 `what=video` 配 `bvid`,或 `bili_watch`),并提前说清「翻了别的、搜了别的,都不算这一条」;「你递过来、只是没打开」的那种另有措辞。仍然不透露配额与开关。`test/p4.test.mjs` 新增 2 条守门。 ## 0.8.2 — 2026-10-30 这一版没有新功能,只修 bug,都是「那句说出口的话是错的」——显示出来的状态不对、报出来的理由不对,于是真正的原因查不到。 ### 一、醒着,却被显示成「沉在水底」(2026-10-30 m04933) 「她现在在干什么」那一格,在**醒过、但上一次动作在 5 到 10 分钟之前**这一段会掉进最后的兜底分支,显示成「沉在水底」。 原因是判据写错了:那段代码写的是「上次动作超过 10 分钟才算醒着」,而 5 分钟以内另有一支。于是中间那 5 分钟成了一个洞——她其实醒着、也没在做事,界面却在说她睡着了。 改法是把判据从「超过 10 分钟」改成「只要有过动作就算醒着、并报出多少分钟前」,与她「醒来但什么都没干」的定义一致(这本来就是一等状态,不该糊成睡着)。顺带:`IDLE_MS` 这个常量因此只剩导出与测试在用,保留是为了不破坏已有的引用。 **测试**:`test/status.test.mjs` 新增 1 条——醒着、上次动作在 7 分钟前,必须仍是「在吃大白饭」且 detail 报「7 分钟前」。 ### 二、本机没装 ffmpeg 时,看画面报的理由是错的 `grabFrames` 原来的顺序是:校验 BV 号 → **探测 ffmpeg** → 取视频信息 → 取播放地址 → 抽帧。 于是机器上没装 ffmpeg 时,任何上游错误(接口 412、要登录、视频没了、地区限制)都会被「本机没有可用的 ffmpeg」这句盖掉。用户照着这句话去装 ffmpeg,装完发现还是不行——真正的原因从头到尾没露过面。 现在探测挪到**拿到播放地址之后**:先把上游到底怎么说原样透出来,确定能抽帧了再抱怨缺工具。 这条是 CI 上四个 job 同时红才发现的:runner 没有 ffmpeg,两条「上游怎么说就怎么说」的用例看到的是 ffmpeg 文案。**本机装了 ffmpeg,所以本地一直全绿**——典型的「只在开发机上成立」。 **测试**:`test/video.test.mjs` 新增 1 条——注入 `ffmpeg: { ok: false }` 但上游抛 412,`why` 必须是「接口 412:请求被拦截」,不能是 ffmpeg 文案。 ### 三、`DSH_FISH_FFMPEG` 设成空字符串会让探测直接抛异常 `detectFfmpeg` 原来写的是 `String(env[FFMPEG_ENV] ?? 'ffmpeg')`:变量**没设**时用默认值,但**设成空串**(`set DSH_FISH_FFMPEG=`,Windows 上清变量常见的写法)时不走默认值,直接把空串交给 `spawn`,抛: ``` TypeError [ERR_INVALID_ARG_VALUE]: The argument 'file' cannot be empty. Received '' ``` 改成 `String(env[FFMPEG_ENV] ?? '').trim() || 'ffmpeg'`:空串与没设一律当作没设。 **测试**:`test/media.test.mjs` 新增 1 条——`detectFfmpeg({ DSH_FISH_FFMPEG: '' })` 不许抛,返回值形状照旧。 ### 四、`tools/check-install.mjs` 在 Linux / macOS 上算不对副本路径 `parseFileDep` 里用 `path.resolve(profileDir, raw)` 拼 `file:` 依赖的源目录。POSIX 的 `resolve` 不认识盘符:`resolve('/home/runner', 'D:/DSH/x')` 得到 `/home/runner/D:/DSH/x`——一个不存在的路径。Windows 上 `resolve` 认得 `D:`,所以本地一直是对的。 修法是不把 Windows 绝对路径交给 `resolve`:认出盘符(`^[A-Za-z]:[\\/]`)或 UNC(`^\\\\`)就原样规范化返回;只有当**profile 目录本身**是 Windows 路径、而依赖是相对路径时,才用一个小函数按盘符手动拼。 **测试**:`test/installcheck.test.mjs` 原有 8 条(含这条断言)在 Linux CI 上跑通。 ### 五、测试辅助函数把临时目录删早了(只影响 CI,不影响插件行为) `test/grant.test.mjs` 与 `test/keepsake.test.mjs` 的 `withRoot` 原来写的是: ```js try { return fn(root) } finally { rmSync(root, { recursive: true, force: true }) } ``` `fn` 是 async 的时候,`finally` 在**第一个 await 处**就执行了——函数体还没跑完,临时目录已经没了。随后 `fs.readFile` 若没抢在删除前 `open`,就撞 `ENOENT`,`currentOffer` 返回 null,用例报 `null == true`。 这是**时序**问题,不是逻辑问题:本机快,`open` 抢在前面就过;CI 的 `ubuntu-20` 慢一些,0.8.2 第一次推送(run 41083997221 / job 124160216212)就红了。它同时解释了 0.8.1 期间那次被记成「偶发」的 1077/1 fail——当时没找出是哪条,现在知道是这一类。 改成:同步返回值直接清理,返回值是 thenable 就用 `.finally()` 等它跑完。`test/keepsake.test.mjs` 的同款写法暂时没改,它的 7 处调用全是同步的,撞不上;这里记一笔,以后给它加异步用例时会踩。 ### 测试 全量 **1078 条全绿**(本机、前后端各跑三次)。CI 四个 job(ubuntu 20/22/24 + windows)全绿(run 41085171602)。 ## 0.8.1 — 2026-10-30 起因是一次误判,但结论仍然值得做:`userQuestions`(「她在会话里直接问你」用的服务)从 0.7.5 起写进了 `inject`。它是个**可选服务**——不是每个宿主都装配它。cordis 的规则是「`inject` 里有一个服务不存在,插件就不启动,而且不报错」,所以只要哪个宿主没装这个服务,整个插件就会**静默消失**:日志零行、界面无提示、工具全无,看起来和「没装插件」一模一样。 那次的真实故障与此无关(是 `node_modules` 里落了空副本、`pnpm install` 静默失败),不能再拿它当证据——但把可选服务写成硬依赖,是**下一个**同类事故的现成剧本。所以 0.8.1 把它拆掉。 ### 一、`userQuestions` 不再写进 inject `inject` 回到四个必需服务:`sessionController`、`tools`、`webServer`、`connection`。 - boot 时探测 `ctx.get('userQuestions').ask` 是不是函数;不是就 `logger.warn` 一行并关掉这条路,别的照常跑。 - `lib/askinchat.js` 改成运行时取服务;缺了返回 `NO_SERVICE`(这个兜底本来就有,只是以前根本执行不到)。 - 判据:**只影响一条功能的可选服务,不该有让插件整个不装的权力**。以后新增这类服务一律运行时探测。 ### 二、设置页把这句说明白 `/state` 新增 `compat: { checked, userQuestions }`(`lib/observe.js`)。「数据」页(导入卡下面、重置按钮上面)在宿主缺这个服务时出现一张「这个宿主没提供『会话里问你』」的卡:讲清关的是哪条路(她能在对话里问你)、别的功能不受影响、想恢复就升级宿主。 ### 三、宿主版本的最低保底 - 0.8.1 起:只要宿主提供上述**四个**服务,就能装、能用。 - 「她在会话里问你」这条路,另外需要宿主装配 `userQuestions`;没有就关掉,不影响启动。 ### 四、标注适配的 DSH 版本(市场通用写法) 以前 package.json 里只有 `engines.node`,没有任何 DSH 版本声明——用户装之前无从判断。现在按市场惯例补两处: ```json "engines": { "dsh": ">=0.2.0-rc.1 <0.3.0-0" }, "dsh": { "compatibility": { "dsh": ">=0.2.0-rc.1 <0.3.0-0", "dshReleases": { … }, "profiles": ["desktop","web","headless"] } } ``` - `engines.dsh` 是宿主真读的那条(`dsh-api-remotes` 里会按 `engines.dsh` 报不满足项)。 - `dshReleases` 逐版本标 `compatible` / `unknown`:只在真跑过的 `0.2.1-rc.2`、`0.2.1-rc.3` 上写 `compatible`,其余写 `unknown`(= 没测过,不等于不支持)。 - 新建 `lib/hostver.js`:启动时从插件位置向上找宿主 `package.json`,版本在范围外就 `logger.warn` 一行;`/state.host` 交出去;设置页「数据」页在 `older` / `newer` 时多一张卡。**只提醒,不拒绝启动**——旧版本里大部分功能仍然可用。 - 读不到版本(桌面版把宿主装进 `app.asar`)记 `unknown`,不算不兼容。 - 下限为什么是 `0.2.0-rc.1`:插件用到的接口在 `dsh-tools@0.1.0-rc.8`、`dsh-api-session-controller@0.1.2-alpha.2`、`dsh-workspace@0.1.0-rc.8` 里已有,但**没在那些宿主上跑过**;声明的是测过的范围。 测试:新建 `test/hostver.test.mjs`(10 条:向上查找、桌面版包名、坏 json 跳过、范围解析、supported/older/newer/unknown 四态、`package.json` 与代码常量不许分叉、`dshReleases` 值域)。 ### 五、装不上的时候,从外面查:`tools/check-install.mjs` 插件没进来的时候,插件自己是看不见的(它压根没被执行)。所以补一个**不需要插件先跑起来**的检查脚本:零依赖,`node tools/check-install.mjs` 就行。 它做四件事: 1. 扫 `~/.dsh/profiles/*` 里所有装了 dsh-fish 的 profile(`--home` 可改,`--profile desktop` 只看一个); 2. 认出 `file:` 安装方式,定位源目录;`link:` 安装会明说「没有副本,不需要比对」; 3. 比对源目录与 `node_modules/dsh-fish` 的**版本号**和**文件数**; 4. 给出四种判定:正常 / 装的是旧副本 / 副本空的或装了一半 / 不是这套装法。 退出码 0/1/2,适合塞进脚本里。判定为「副本空的」「装的是旧副本」时会直接给修法。 **为什么这个只能放在外面**:`node_modules/dsh-fish` 是空目录时,DSH 加载不到插件 → 插件里的 `checkUpgrade`(0.8.0)也没机会跑。这两道检查互补:装进去了靠插件自己报,装不进去靠这支脚本报。 ### 六、看画面在 workspace-write 下必然失败(issue #13,@LateApology) 报告的现象是:`bili_watch` 永远回一句「一帧都没取到(可能被风控、或者这条视频不允许外链拉流)」,但同一时刻 playurl 和 `bili_video` 都正常。 根因不在 B 站,在我们自己:以前把输出路径**直接交给 ffmpeg**(`-i url … tmp/f01.png`),而 DSH 的文件策略限制子进程往 `~/.dsh-fish` 写文件,于是 ffmpeg 报 `[image2] Could not open file … Permission denied`、退出码 -13。插件进程自己写得进去,子进程不行——这个区别全被「可能被风控」那句话盖住了。 三处改动: 1. **帧和拼图一律走 stdout 管道**。抽帧用 `-f image2pipe -vcodec png -`,插件自己 `fs.writeFile`;拼图更彻底——把各帧 PNG 顺序拼起来喂 ffmpeg 的 stdin,连中间帧的临时目录都省了。子进程现在一个写权限都不需要。 2. **失败原因是真实的**。带 ffmpeg 退出码 + stderr 前两行,并 append 到 `media/ffmpeg.log`。「没找到 ffmpeg」和「找到了但跑不起来」是两句话,不再混在一起。 3. `-headers` / `-ca_file` 只在 http(s) 输入时才加。它们是网络协议私有选项,对本地文件输入会直接 `Option headers not found.` 退出 1(实测)。`caFile` 可通过 `DSH_FISH_CA_FILE` 指定;**给了 CA 就同时加 `-tls_verify 1`**——ffmpeg 默认不校验证书,只给 `-ca_file` 而不开校验的话 CA 用不上(实测传一个不存在的 CA 文件也照样出图,stderr 只有一句 `Error while reading file.`)。默认不校验,行为与改动前一致;只有你显式给 CA 才进入校验模式。 顺手消掉的老问题:旧的 `tmp-*` 临时帧目录其实一直没被 `sweepMedia` 清掉(它只删 `isFile()` 的条目,子目录直接 `continue`),改管道后这个目录根本不存在了。 测试:新建 `test/video.test.mjs`(10 条)。其中三条是源码守门,防止以后有人把输出路径加回去——那种改法在完整权限下照样绿,只有在 workspace-write 的真机上才会炸;另一条守 CA 参数必须成对。真跑管道那条在环境里没有 ffmpeg 时 `skip`,不为绿把断言删掉。全量回归 1046 条全绿。`test/boot.test.mjs` 补「`inject` 不含 `userQuestions`」;`test/client.test.mjs` 补宿主旧卡片一条(出现条件、标题、要说清关的是哪条路、位置在升级自检之前)。 ### 七、回家之后,那条会话也得知道「这一段结束了」 用户报告:从「工作园区」出门再回家,那条会话还认为自己是大肥鱼。 根因不在身份段——0.6.11 起系统提示已经能撤干净。问题在 append-only 的历史:请神结束时我们往被接管的那条会话末尾补一句「已经还给你了」,出门结束时没有。`detachOutside` 只拆掉身份段与护栏,历史里却全是她以自己身份说过的话,下一条消息自然沿着那个身份往外演。 三条路一起补: 1. **出门结束**(工作园区与别的工作区一样)往外面那条会话写一条边界标记:「(出门结束了:这条会话交回给「X」原来的助手。)」「刚才那一段里,大肥鱼是临时顶班——那些话不是这条会话自己想说的。」它不评价她做过什么,也不提驳回——驳回的话归家里那条会话。 2. **会话当时没开着**,就排进 `session-notes.json`,等它下次打开时补投;「接回上次那趟」(复用出门会话、请神接回同一条对话)时也会先投一次。 3. **请神那条路**补上同样的离线兜底:以前 agent 不在线就只记日志,那句「还给你了」会丢。 投递按 `sessionId` 认:带 id 的只投给它自己那条;老的无 id 条目只给家里会话(0.6.16 的驳回话术),不会串进别人的对话。没投出去的条目留在文件里,不丢。 测试:`test/outing-route.test.mjs` 补两条(写回活会话 / 排进队列),`test/outing-start.test.mjs` 补请神离线排队一条,`test/sessionnote.test.mjs` 补按 sessionId 分投一条。全量 1046 条全绿。 ### 八、「明天中午提醒我做某件事」——她自己就能定下来 用户提的需求:想让大肥鱼在某个时间自己醒来去做一件事(比如中午去看邮箱、按信里的事做、再回信),而不是每天固定时间被叫醒之后自由发挥。 核查结论:这是**定时任务**,宿主本来就有(`@deepseek-ai/dsh-tool-schedule` 的 `schedule_create` / `schedule_list` / `schedule_update` / `schedule_delete`),不需要在插件里再造一份调度器,也不需要加读文件/发邮件的工具——那些该由别的插件提供。缺的只是「她够得着」和「她知道能用」: 1. **工具面:默认不放,谁想用谁自己加**(用户 m04504)。默认工具数不变(八个 + 宿主给的 `read_image`)。`SCHEDULE_TOOLS` 只作为一份清单导出,用户在设置页「插件能力白名单」里填 `schedule_create`(要让她改/删任务,再连 `schedule_list`、`schedule_update`、`schedule_delete` 一起填),它才会进工具面。放行之前,她答应「到点提醒你」仍然是空话——界面会直接告诉她「默认不放,工具数不变」以及怎么开。 2. **提示词跟着放行走**:`lib/persona.js` 的 `SCHEDULE_BRIEF` 只在白名单里有 `schedule_create` 时才由 `lib/selfdoc.js` 挂进「我手上有什么」——否则就是告诉她「你有这个工具」,伸手却没有。内容写清怎么定(title / prompt / daily·weekly·at / 时区)、一件事定一条、到点会以「用户发来的定时消息」出现、以及**那天在歇着或正在出门/请神时先记下来别做**——宿主投递不经过插件这几道闸门,这条只能靠她自己把住。 3. **不重复记轮数**:定时消息的 `source.kind` 是 `schedule`,`classifyMessage` 本来就把非 `user` 归为 `context`,不会让「聊满 N 轮换会话」被提醒提前触发。补一条用例钉住。 4. **与醒一醒互不影响**:唤醒走的是 `setWakeSection()` + `WAKE_TRIGGER`,提醒走宿主投递;两套闸门(窗口、请假、出门)只管唤醒,不会被提醒占用。 安全边界不变:提醒正文是模型可见的普通文本,别放密码;她只是在同一条会话里多了一个「到点被提醒」的入口,没有新增读文件或发邮件的权限。 测试:新建 `test/schedule.test.mjs`(6 条:默认不放、放行后手边清单点名、说明书内容、身份文档条件挂载、设置页按白名单分叉、轮数判据)。全量 1060 条全绿。 ### 九、日志里的「看了什么」,只算真的看过画面 反馈是这样来的:她说自己那天没看过那几条——只扫了眼标题和评论,一帧画面都没抽;可当天的日志里它们全在。反过来也有:你专门递给她一条视频,日志里却找不到。 两句话都对,因为代码里「看过」一直有**三个意思**,而报告只认了最松的那个: | 记什么 | 什么时候写 | 她实际做了什么 | | --- | --- | --- | | `browse:` / `browse-more:` | 刷完站、把条目拼进素材 | 读了标题、简介、标签、热评、弹幕——**没有画面** | | `look:` | `bili_look what=video` | 同上,**没有画面** | | `watched:` | `bili_watch` 抽帧成功 | **真的看了九帧拼图** | | `hand:` | 你在对话里递来一条视频 | **只是递到手上,她可能还没看** | 报告、历史、状态窗全部只读第一行;`watched:` 只被两处读过(当日额度计数、评论闸门的准评名单),`hand:` 一处都没读。于是「扫了眼标题」和「真的看了画面」在日志里长得一模一样,你递过去的那条则彻底不出现。 改法是把口径收进一个文件,让四个面共用同一份判断: 1. **新建 `lib/whatseen.js`**,只导出三档——`watched`(看过画面)、`glanced`(翻到过,已剔除前面那档)、`handed`(递过来了、两档都没进)。`seenOn(events, dk)` 一次给全。三档互不重叠,同一条视频不会两边都算。 2. **报告(每日日志)分三节**:`## 看了什么` 只列真看过画面的,一条没有就写「今天没看画面。」;新增 `## 翻到过什么`,副标题写明「只有标题、简介、评论和弹幕——没有画面。翻到过不等于看过。」;新增 `## 你递过来的`,副标题「递到她手上了,她还没看。」 3. **`watched:` 事件补上展示信息**:以前只记一个 BV 号,想显示标题只能退回去用 `browse:` 记账凑——这正是混在一起的根源。现在追加一段 JSON(标题 / UP / 时长 / 播放量),旧的裸 BV 号照常能读(兼容只收在 `parseDated` 一处),坏 JSON 只丢展示信息、不丢 BV。 4. **历史记录**:真看过画面的排在最前并标「看过画面」,翻到过的按天跟在后面;「你不在的时候」从一句「看了 N 条」拆成「看过画面 A 条 / 翻到过 B 条」;今日概览同一口径。 5. **评论闸门不动**:翻到过、主动看过、看过画面的都能评,递过来没看的仍然不能评——这条本来是对的,只是提示语文案没跟着分档,这次没改它。 内部注释里剩下的「看过」(`lib/browse.js` 等处指 `browse:` 记账)没有改:它们说的是去重逻辑,不是给人看的文案。 测试:新建 `test/whatseen.test.mjs`(14 条:老/新两种 `watched:` 形状、坏 JSON 只丢展示信息、三档互不重叠、跨天不串、报告三节顺序与「只翻到的」不许出现在「看了什么」段内、老调用方只传 `items` 时回落旧口径、历史里看过画面的排最前、`summarizeAway` 两档分开数)。全量 **1074 条全绿**。 ### 十、归档的是那一条,不是整个家(2026-10-30 m02853) 房客复核日志里那条唯一还缺的:`home.json` 指着的会话被归档之后,同一个工作区里可能还有另一条没归档的会话,插件却只认 `home.json` 一条,直接宣布「这个家的会话被收起来了」。 - 两条归档判定分支(`home.json` 里的 `status:'archived'`、以及宿主 `archivedSessionIds`)在拒绝之前都先看同屋:还有别的、没归档的会话就认那一条(`pickLiveSession` 取 `ws.sessionIds` 里最近的一条),把 `home.json` 改指过去、清掉归档标记,返回 `{ ok:true, reused:true, adopted:true }`。 - 一条都没剩,仍然说「被收起来了」,不硬造一条——「我收起来了」这句话还是不能被插件自己推翻。 - 认来的那条不复用旧会话的 `createdAt`:10 分钟宽限是给「刚建出来的那条」用的,借别人的时间戳会白享宽限。 - **测试**:`test/home.test.mjs` 新增 3 条(同屋有活会话 → 认它且不新建 / `home.json` 已落盘 archived 之后重启也认得回来 / 同屋全归档 → 仍然拒绝)。 ### 十一、自检不再冤枉公开版「少装了一个工具」 发版前自查发现的一条必然误报,公开版**人人都会撞上**: 有一个工具只在**开发副本**里注册(`.dev` 存在时才挂上),可它是白名单来源 `FISH_TOOLS` 的一员,自检早先直接拿这份清单跟宿主 registry 比「在不在」——公开版没有 `.dev`、也就不可能注册它,于是装完自检在「手上的工具」这一项必然报红: > 有 1 个工具没注册上:……她现在说不出话、也记不下东西。 这句红字的后果不是「少个功能」,是**让人以为整个插件装坏了**——它说的「说不出话、记不下东西」恰恰是最吓人的那种措辞。 这是 2026-10-28 那次 `read_image` 误报的同一类错误:拿**名单**当「真装上」的证据。那次是拿全局工具表当结论,这次是拿**本机清单**当公开版清单。 修法:判据与注册处同一份——`lib/selfcheck.js` 引入 `devModeSync(root)`,没有 `.dev` 时把那个仅在开发副本注册的工具从「应该装上」的清单里去掉;顺带把这一项里两处硬编码的「7 个」改成按实际清单计数(`FISH_TOOLS` 早就是 9 个,那句「我们给的 7 个」已经过时)。 **没有把整项放过**:开发副本里那个工具缺了照样报红,公开版里别的工具缺了也照样报红。 **测试**:`test/tools.test.mjs` 新增 1 条三面守门——公开版(无 `.dev`)不因它报红且措辞里不出现它 / 开发副本(有 `.dev`)缺了必须报 / 公开版缺 `bili_watch` 仍报红。全量 **1075 条全绿**。 ## 0.8.0 — 2026-10-30 > **升级须知(0.7.x → 0.8.0)** > - 设置页多了一个「出门」页签,排在「上网」和「记忆」之间。第一次进去要先点一下「我知道了,开始用」,之后不再问。 > - **出门是实验功能**:它会真的把一条对话交出去,也会读那个工作区的项目笔记、交接记录、记忆文件。最坏的情况不是数据坏了,是她可能把不该看的内容写进日记——日记撤不回。不能接受这一点,就别点那个确认。 > - 想退回去:从 [v0.7.5 的源码包](https://github.com/ssamurias/dsh-fish/archive/refs/tags/v0.7.5.tar.gz) 解压,再用「设置 → 插件 → 从本地安装」装回去。数据格式没变(事件仍是 v1),`~/.dsh-fish` 不用迁移,回退也能照常读。 > - 她不问就代表没同意:请求事件是她自己开口(一天 1 次、一周 3 次),你不答就过期作废。 两个实例同时开同一个家,会多长一条会话。0.6.8 的单飞锁只在**进程内**,管不到另一个进程;`bootstrapHome` 也不看占用权,第二个实例照样走「建 / 复用」那条路。房客日志里成对的 `home.json.*.tmp` 就是那个窗口留下的。 ### 一、占用中的家:这边只读,不建会话 `bootstrapHome` 现在第一件事看 `instance.json`:这个家被别人占着就**什么都不建**(连工作区也不建),把 `home.json` 里已经记着的那条会话报上去;`/state.home.blocked` 为 `true`,设置页出现「这个家在别处也开着」的卡片,写清是只读、以及怎么真正并存。 真机效果:双开时第二个窗口不再长新会话,也不叫醒她(不会白烧一轮);把另一个关掉之后,这边下次启动自动接管。 ### 二、跨进程一把锁 `lib/tenant.js` 新增 `withFileLock(file, fn, {ttl, timeout, wait})`:`wx` 独占创建 + mtime 判陈旧,零依赖。`ensureTalkSession` 的整段临界区(读 `home.json` → 判活 / 归档 → 建会话 → 写 `home.json`)现在在这把锁里跑,锁文件是 `/.talk-session.lock`。 - 拿不到锁(等 8 秒)时**不硬建**:读一眼 `home.json`,有会话就复用(返回 `waited:true`),没有就返回失败,下一次启动再来。 - 上一任进程死在临界区里(锁文件还在、mtime 超过 30 秒):当陈旧锁直接接管,不会永久卡死。 - 锁的释放放在 `finally`:`fn` 抛错也收得回;等锁超时的那一方**不会删**别人的锁。 ### 三、升级自检:跑的是不是你要装的那一份 `file:` 装法的人升级时,通常的做法是覆盖 profile 里那条 `file:` 指向的源目录,然后重启。可 DSH 加载的是 `node_modules\dsh-fish` 里 pnpm 落的那份**独立副本**——源目录换了,副本没换,跑起来的还是旧代码。现象是最难查的一种:没有报错、没有日志(新代码根本没执行),界面上看就是「升了没变」。 `lib/upgrade.js` 启动时读一遍 profile 的 `package.json`,把那条 `file:` 依赖指到的源目录版本和正在跑的 `VERSION` 比一比: - 对不上 → 设置页「数据」页最上面出现一张卡,直接写「升级没有真正装进去」,并给两条修法(`pnpm install --force`,或删掉 `node_modules\dsh-fish` 再启动); - 只有 `link:` 依赖、读不到、json 坏了 → 静默跳过(`skipped:true`),绝不因为查这个把启动搞砸; - 结果同时进 `/state.upgrade` 与启动日志(`stale` 时 `logger.warn` 一行)。 ### 四、不签名的接口也带上匿名 buvid3(社区 PR #10,@BBleae) 不带 cookie 建出来的 client 只在**签名**接口前取 buvid,`popular` / `view` / `tags` / `reply` 全程匿名。`/x/web-interface/view` 在没有 buvid3 时多半回 `-412`,而读熔断会把同一轮剩下的请求一起关掉 60 秒——表现就是「只剩标题和播放量,别的什么都没有」。 `lib/bili.js` 的 `api()` 现在在不签名分支里先看一眼 cookie:没有 `buvid3` 就先 `await ensureBuvid()`。失败后 10 分钟内不再重试(`buvidFailAt`),避免每个请求都多打一次接口。作者实测:不带 cookie 的 view 六次里五次 -412,带上 buvid3 后五次全通。 ### 五、一天的几次叫醒不再一分钟一个(社区 PR #11,@BBleae) 三个根因:落点一过就每分钟问一次 `plan()`,`running` 只拦到入队;`watchTurnEnd` 认会话不认轮次,叫醒排在她正说着的那轮后面时处境会被那轮收走;90 秒兜底不看这一轮说完没有。 - 新增导出纯函数 `nextWakeAt(events, config, dk)`:间隔 = 剩余时长 / 剩余次数 + 1,下限 45 分钟(`WAKE_MIN_GAP_MS`),结束点取时段结束与当天午夜更早者,配额满返回 0。 - `startScheduler` 用 `ctx.on('session/event')` 维护 `busy`(turn/start 置位、turn/end 清),`wake()` 里先 `await deps.waitIdle(sessionId)` 再挂 `watchTurnEnd`;`BUSY_STALE_MS` 15 分钟兜住没收到的 turn/end。 - 90 秒兜底改成「她还在忙就 30 秒后再看」,不再到点硬清。 ### 六、设置页「去上网页存」点了没反应(issue #12,@TulleyT) `lib/client.js` 那张「还没存 B 站登录」的卡上,按钮写的是 `onClick: () => showTab('bili')`。而 `showTab` 是**判据**(`const showTab = (id) => tab === id`,只回答「现在是不是这一页」),切页是 `setTab`。返回值被丢掉——不跳页、也不报错,看起来就是点了没反应。从 0.6.1 起就这样,一直没人点到过。 改成 `setTab('bili')`,并加了一条测试守这个**类型混用**:判据不许出现在返回值被丢弃的位置(`=> showTab(` 在 `lib/client.js` 里应当为零处)。 ### 七、`bili_speak` 的说明跟着开关走 原来不管说话功能开没开,工具说明都写着「可以给你今天看过的某个视频留一条评论」;而关着的时候身份文档里**没有**「在网上说话」那一节(`lib/selfdoc.js` 按同一个开关判断)。一边说能说、一边一句不提,两个来源当场打架。 现在 `lib/speak.js` 的 `description` 是 getter,读模块级状态;`registerSpeak` 启动时按当时读到的配置定一次,`execute` 里再同步一次。关着时只留一句不带任何许诺的短说明——它不承诺发得出去,也不解释为什么发不出去(说「开不了」等于告诉她有个开关在管着这件事)。 ### 八、出门干活时她把工具当成「屋子里的东西」,自己把自己劝退了 真机第一趟出门口袋:让她「用 growth_commit 记一条今天出门的事」,她回「这件事我做不到……growth_commit 就是屋子里的东西——它只记我自己说的话,归在那边,不属于这边的地界」,然后提议在口袋里另写一张纸。整趟 `events.jsonl` **一条没写**(大小与出门时的 baseline 一模一样),回家清单空着,看起来像功能坏了。 功能没坏,是提示词自相矛盾: - `outingWakeText`(`lib/observe.js`)第三句「只有一件:你自己的屋子里的东西不要动——那不是这趟该碰的」; - `toolBrief`(`lib/persona.js`,会被身份快照**原样带出门**)「上面这几样是你自己的屋子里的东西」。 两句都把**工具**和**屋子**黏在一起。她据此推理:growth_commit 记的是「我自己说的话」→ 归属屋子 → 这趟不许碰 → 拒绝。逻辑自洽,所以再让她写几次也一样。要禁的从来是**家里那堆文件与记录**(`attachAwayGuard` 挡的也只有路径)。 改法: - 出门首轮改成「我自己屋子里的**文件和记录**不要动」,并显式加一句「工具不是屋子里的东西。这条对话给我什么,我就用什么;用它写下的东西照样记在我自己的账上,那是应该的,不是越界」; - `toolBrief` 改成「上面这几样是你自己的工具,跟着我走,在哪儿都能用——它们不是屋子里的东西」,并补一句「在外面用它们写下的东西照样记在我自己的账上」; - 顺带修一处**事实错误**:回家卡的口袋分支原来写「你这边没有她可用的工具」——她的那几个工具是插件级全局注册的,口袋里一样在手上(只是没有那个工作区的 `bash`/`read`/`write`)。改成如实说明,空清单的原因指向「她这趟没写」。 两条新用例:出门首轮必须点明「文件和记录」、必须显式把工具摘出来、且原来那句不许再出现;`toolBrief` 不许再把工具说成屋子里的东西。 ### 九、「叫醒被挡住」这件事以前不留痕 0.7.4 给手动 `/wake` 加了「她不在家就不叫」的闸门,但那道闸门是**静默**的:`wakeNow` 在最前面 `return { ok: false, away, why }`,`log()` 还没定义,一行都不写。 后果是事后没法验证。真机上出门期间点了一次「叫她一次」,日志里空空如也——分不清是「用户其实没点」还是「点了被挡回去」。当时只能靠反证:全库 `meta wake:` 标记共 7 条,最后一条在出门开始**之前**,出门窗口内一条都没有,据此推断「没被误唤醒」。这个推断成立,但它证明的是反面,不是正面。 现在挡住的瞬间落一行 `叫醒被挡住了(outing):此刻在外面:出门口袋 · 已 1 分钟`,走 `logLine` 直接写盘(`deps.log` 那时还没就绪),顺手也转发给宿主 logger。出门那次用例里加了两条断言钉住它:必须有这行、而且得说清她在哪儿。 ### 十、日志:该记的没记,不该记的刷屏 用户 m02241 让把「对查 bug 很重要、但现在没有任何规则去记」的东西全修一遍。查下来是两头都出错——**关键路径一声不响,噪音路径每 60 秒写一行**。 **噪音这头(用户明说的重点:成功的心跳不要记)**: - `away 此刻在外面:X · 已 N 分钟` 以前是每次 tick 都写,出门一整晚就是几百行同样的字。现在只在**这一趟第一次**看到时记一行(内存里存 `awayLogged`,key 由 `kind|sessionId` 组成),回家清空;只有台账**冲突**(出门与请神同时 active)才每次都记——那是真的异常,不能去重。 - `quiet 今天已经记过「没有该问的事」,不再重复写` 这句本身就是那句「不再重复写」,却每 60 秒重复一遍。事件流里已经有 `quiet:` 了,日志不需要说第二次,直接删掉这行。 - `skip`(配额满、她正在说话)0.7.2 起已经按「同一小时 + 同一原因只记一条」去过重,本次不动。 **缺口这头(以前出事现场无痕)**: 1. `lib/store.js` 的 `appendEvent` 是所有人写事件的唯一入口,它的两个失败点(`validateEvent` 拒收、`appendFile` 写不进)以前**一句话都不留**,全靠调用方自觉。0.7.4 那三个 `appendEvent` 参数错位 bug 之所以在 0.6.x 潜伏了整个版本线,就是因为它被 `.catch(() => {})` 吞掉了。现在两个失败点各自 `logLine`(`事件被校验拒收:type=… source=… 原因` / `事件没写进去:…`)再照原样 `throw`,调用方语义不变。 2. `lib/tenant.js` 这个文件以前**一行日志都没有**。`checkCaller` 拒绝一次写入只返回 `{ok:false, why}`——而「她写了但没落盘」正是最典型的故障形态。现在拒绝时落 `写入被拒(不在这个家):tool=… session=… cwd=… root=…`;`guardWrite` 的「家被另一个实例占着」也补一行。 3. `lib/restrict.js` 的守卫拒绝以前只说工具名,不带会话、不带参数;`lib/identity.js` 的 `attachAwayGuard` 连工具名都不记。现在两边都补齐:`挡住了一次不该有的调用:<工具> · session= · <参数摘要>`、`在外面挡住了一次伸回自己屋里的调用:tool=… 碰的是「<命中路径>」 · session=…`。 4. `/config` 保存的两处静默 400(白名单拒收、工具名校验拒收)补日志。自动保存是**整表提交**,只要有一个顶层键没登记,整张表都存不进去,用户只看到「没存上」——历史上真出过「漏登记 bili 组」那次。 5. `lib/say.js`:屋子没接上时补一行(「设置页说了话她没反应」最常见的根因);她一条都没说时补一行 `say: 递进去了,她这次没说话 session=… waited=…ms`(她开口了的话事件流里本来就有,不再重复)。 6. `lib/log.js` 的 `scrub` 以前把自家标识符也当凭据抹掉:`session-` 有 42 个字母数字字符,正好撞上「40 位以上字母数字混杂」那条规则,于是日志里全是 `会话 ***`、`woke ***`——**要查「是哪条会话出的问题」时日志一个字都给不出**。现在先把 `session-`、`session-<短名>`、`ev_` 三类摘出来,跑完脱敏再放回;真凭据(`SESSDATA`、`bili_jct` 等)照旧抹掉。 ### 十一、出门口袋排到她的家下面,也能接回上次那条会话 用户 m01078 看的是侧栏:口袋是第一次出门时才建的,比她的家晚,却排在它上面——侧栏第一眼看到的是「出门口袋」,不是「大肥鱼的家」。要的顺序是 ①大肥鱼的家 ②出门口袋 ③之后才是宿主本来建的那些。 排序走宿主的正规入口,**不手改 `workspace.json`**——那是宿主的内存态,手写会被下一次写盘覆盖: - `lib/pocket.js` 新增 `findPocketWorkspace(ctx, config)`:只看不动,按路径认出已登记的口袋工作区;启动时排序要用它,而排序不能为了排序先把目录和登记造出来。 - 新增 `ensurePocketOrder(ctx, { pocketWsId, homeWsId })`:`workspaceRegistry.insertBefore(口袋, 家的下一格)`。已经在紧挨着的下一格就什么都不做(幂等);老宿主没有 `insertBefore`、或表里找不到某一方,返回 `{ok:false, why}`,调用方只记一行日志,不挡任何事。 - `lib/index.js` 在 `bootstrapHome` 之后跑一次。**放在每次启动**而不是只在建口袋那一刻:老用户的口袋早就排错了,得让他们升级后第一次启动就自己归位。 第二件事(用户 m00972):送她去口袋以前一律新开一条会话,现在也能接回上次那趟。后端 `/outing` 的 `sessionId` 复用逻辑本来就是两类工作区共用的一条路(按 `cwd` 校验是不是同一个地方),口袋的 `cwd` 是固定的,天然对得上——缺的只是数据与开关: - `GET /outing` 的 `pocket` 多一个 `last: lastOutingForCwd(...)`(台账认 `cwd`,和普通工作区同一套)。 - 出门页口袋卡多一个勾选框「接着上次那趟」,只在有上一趟时出现;不勾就是新开一条。 ### 十二、请神也要走完同一条回家审查 用户 m02241 批的五条。设计 `docs/E-出门与请神-方案.md` §5 早就定死「两条路共用的四件事」——同一套身份判据、同一套防改自己、**同一套事件标记与回家审查**、同一个调度暂停。出门那条早就做完了,请神这条一直缺:结束请神只做一次 `releasePossession`,没人比对事件流、没人看这趟写了什么、也没有 `possess-reject:` 标记。等于说,请神期间她写下的一切默认全算,文件被人动过也一样算。 - **身份放行**(`lib/tenant.js`):`checkCaller` 在出门分支后面加一条 3.6——请神期间,被上身的那条会话可以把东西写回她自己的家。判据与出门同源(`activePossessionSync`,从 `possess.js` 搬到 `outing.js` 是为了断开 `tenant → possess → identity → tenant` 的环,`possess.js` 只做 re-export)。身份写入那条路仍然 `outsideOk:false`,请神期间她也改不了自己。 - **结束即结算**(`lib/observe.js` 的 `POST /possession` `action:'end'`):在 `releasePossession`(一调用 active 就没了)**之前**读出台账里的 `baseline` / `startedAt`,跑 `verifyEvents`;被改写就判 `tampered`,这趟先不算数(她本人照样回来,挂载照拆),只等复核。其余按 `kept` / `rejectedIds` 拍板:整趟不算 → `eventsSince` 里非 meta 的条目全标;挑着不算 → 只标挑中的。标记写成 `meta possess-reject: `,再由 `deriveSelf` 跳过——与 `outing-reject:` 同一套机制。 - **复核**:新增 `POST /possession { action:'review' }`,只处理 `status:'pending'` 的那一趟,文件还原就当场收下(与 `/outing` 的 review 语义一致)。`GET /possession` 补齐 `verify` / `since` / `pending` / `pendingReview`,界面不用自己算。 - **基线换成全文件哈希**(`lib/outing.js`):`snapshotEvents` 多记一个 `hash`,`verifyEvents` 比的是**开头 `baseline.size` 字节**的哈希——追加不影响这一段,所以「追加了,但把前面某行改了」也躲不掉;只有尾部哈希的旧基线退回原来的尾部比对,不会让历史台账集体变成「判不了」。 - **趟次标记**(`lib/capture.js` / `lib/derive.js`):日记的 meta 在出门的 `trip:` 之外再认 `possess:`;`deriveSelf` 同时认 `outing-reject:` 与 `possess-reject:` 两个前缀。 - **驳回话术的落点**抽出 `rejectNoteToHome`:请神那条是别人的对话,不该在那儿吵她认不认——话送到**家里**那条会话;家里没人开着就排队等她回来。出门那条也改用同一个落点。 - **回家审查卡分两栏**(`lib/client.js`):点「结束请神…」先摊开——左栏逐条列出这趟她写下的(默认全勾),右栏是事件流比对结果与三个按钮(这趟收下 / 整趟不算 / 再等等);`status:'pending'` 的那趟单独一张卡写清原因,文件还原后点「复核一次」。文案跟结算保持一致:说「不算数」就说清几条被跳过。 - **审查列表不摆记账行**(`lib/outing.js` 的 `eventsSince`):`capture` 每写一条成长都会补一行 `meta seen:`,原先它被当成条目摆进审查卡——人看到的是一串数字和英文,勾掉它不改变任何东西,只会往台账里塞一串看不懂的 id(2026-10-30 真机反馈)。现在 `seen:` 一律不进列表;她写的话和 `diary:` 照旧留着。 ### 十三、出门与请神的文案换成第三版(用户 m00650) 「出门」页原来那套话是写给懂这套机制的人看的:事件流、基线、复核、字节数、会话短号。用户把 `docs/F-出门文案-两版对照.md` 从头改过一遍(第三版),这里照它替换。 - **术语**:「出门口袋」→「工作园区」(`lib/pocket.js` 的 `POCKET_TITLE`,登记仍按路径认);「收下」→「算数」;「复核」→「核对」;「事件流」→「记录」。界面上不再出现字节数与会话短号,状态词改成「还没回来 / 等着核对 / 这趟不算 / 已算数」。 - **新增一张说明卡**(`lib/client.js` 的 `OUTING_INTRO`):出门页最上面一块,文字由用户自己写(2026-10-30 m01100,六段:功能定位、实验性提醒、工作园区、送她出门、请神)。写说明只改这个数组,一段一个元素;渲染时「送她出门:」「请神:」的冒号前会加粗成小标题。 - **两处事实纠正**:护栏只作用于**被接管的那一条**对话,不是所有对话;结束请神只能在界面里点,不是「只能由用户消息覆盖」。请神两块与出门那块共三处口径一致(「分不出谁在打字」「屋子、记忆、对话、配置」「只有这条受影响,别的对话照常」)。 - **`pushback` 的语义**:标出来的是「她拒绝过或推翻过的」,不是「不想放弃的」。 - **服务端话术同步**:`verifyEvents` 的四种 why、`awayLine`、请神的 `possessionText`、`GET /outing` 的 warning 都换成同一版口语;复核接口的 409 也说「等着核对」。纪念品说明按实际落点写「她家的纪念品文件夹(按日期分)」。 ### 十四、她可以开口问了:请求事件(用户 m01274 / m01292) 以前她能「要」的东西是定死的:R3 只能申请改一条已有规矩,纪念品只能在出门收尾时问一句。想换作息、想今天歇一天、想问一件事,都没有出口——只能写在话里,等人自己看见。这一版给她一条正规的出口,也给那个人一个明确的「同意 / 不同意」。 - **新事件 `ask` / `answer`**(`lib/schema.js`):都只由宿主写(不在 `HER_TYPES` 里,她伪造一条 `ask` 就等于替自己批准了)。`ask` 带 `kind / subject / options / reason / value`,`answer` 用 `askId` 指回问的那一条,只认 `approve / reject / later`。 - **新工具 `ask_owner`**(`lib/ask.js`):问一句话,弹一张卡。频率写死在代码里——**一天 1 次、一周 3 次**,不进配置:一旦可调,它就变成「让她多问几次」的旋钮,而这件事的重点是她不得不问。没人在线时记下「问了、没答」,不挂第二张卡。 - **只有两种 kind 真能改东西**:`time` 同意后改 `window` 与 `wakesPerDay`(0–4);`rest` 同意后写一条 `meta rest:<日期>`,`scheduler.tick` 开头据此让家里这天不叫她。`rule` 只记账(那句话必须由她自己写进事件流),`need-file / need-tool / other` 只记录「问过、答过」,不落实任何权限——否则一次点头就能拓宽工具面,那等于自改设定。 - **被驳回不追着问**:只写一条 `meta ask-no:`,不重提、不弹第二次;她下一轮从上下文里自然知道。 - **「以后再说」单独记**(`meta ask-later:`):点「以后再说」不等于不同意——混进 `ask-no:` 以后就分不清「她问过、被人否了」和「她问过、先放着」。观测台仍按 `answer.ask.verdict` 显示。 - **超过 24 小时没答**:`settleStaleAsks` 落成 `later`(`via: 'timeout'`),不再挂在待答里。 - **观测台**:住所页新增「她的请求」,列出她问过什么、你怎么答的;弹卡答过或超时没答的,可以在这里补答(`GET/POST /dsh-fish/ask`)。 - **桥接**:只做文本兜底与结算路由,不主动推 IM。A 档(有拦截 hook)才可能用消息卡;B 档(没有 hook)只开无副作用的三类;C 档退化成「她下一轮自己提一次」,系统只记录、不承诺裁决。 - **三处都要写,否则她等于没有这个工具**(用户 m01559 点出来):`lib/restrict.js` 的 `FISH_TOOLS` / `HER_TOOLS` 加 `ask_owner`(收窄名单与「手边有什么」那段同源,名单里没有她连名字都看不见);`lib/persona.js` 新增 `ASK_BRIEF`、由 `lib/selfdoc.js` 挂进身份文档的「我手上有什么」一节(讲清什么时候值得问、上限是多少、被拒之后别再追问);设置页「手上能用什么」那张卡的数字从「六个」改成「八个」,把「有事问你同不同意」列进去。 - **卡片回来的是 `{label}` 而不是字符串时,选择会被当成「没答」**(2026-10-30 真机,用户 m01756):旧 `picked()` 直接 `String(sel[0])`,对象就变成 `[object Object]`,永远对不上选项;那一次她问了,卡片消失,事件流里却没有 `answer` 行。现在 `picked()` 拆 `label / value / text`,`ask.js` 的 `askChoiceIndex()` 再兜一层「去空白 / 纯数字当列表下标」,认不出时**不再猜**,只把 `code` 与原始回执写进日志(`她问了却没结算:NO_MATCH · … · ask=`)。 ### 十五、出门页对所有人可见,第一次进来先确认(用户 m01904) - **不再按「开发副本」藏页签**:`lib/client.js` 删掉 `devMode` 状态与 `TABS.filter(...)`,出门页常驻(`...TABS.map(...)`)。`/outing` 仍带回 `dev`,留给别的本机判断,但不再决定页签显不显示。 - **门改成页面里的一次性确认**:说明卡上方多一张「先确认一下」,写明这是测试版、最坏结果不是数据坏了而是她可能把不该看的内容写进日记;点「我知道了,开始用」→ `POST /dsh-fish/outing { action: 'ack' }` → 配置里记 `outing.ack: true`,之后不再问。 - **按钮挡法**:没确认时「让她去园区」「让她去这个工作区」「请她上身」三个按钮 `disabled`,并给出「先点上面的『我知道了』」的提示;确认后照旧按「她已经在外面 / 没选目标」判断。 - **配置**:`lib/settings.js` 的 `DEFAULTS` 新增 `outing: { ack: false }`,`normalizeConfig` 里补布尔化(老配置没有这一组);`GET /outing` 回包新增 `ack`。 - **只在界面挡,不加服务端硬门**:服务端拦会把命令行调用与现有路由用例一起挡掉,而这里要挡的是误点。 - **测试**:`test/principle.test.mjs` 与 `test/outing.test.mjs` 里三条「按 devMode 藏页签」的断言改成「页签常驻 + 有确认门 + POST 能写 ack」;新增 1 条路由用例「ack 默认 false,点一次才写进配置」(`test/outing-route.test.mjs`)。 - **零工作区的新用户也走得通**(脚本模拟,`D:\deepseekagent\04-小工具\fish-newbie-outing.mjs`,15/15 通过):GET 不抛错、`workspaces` 为 `[]`、`ack` 为 `false`、园区路径算得出来;点确认后再点「让她去园区」会建目录、登记成工作区、建会话、台账落一条 active,回家也正常;登记失败(`registry.create` 抛错)时退回 `sc.create({ cwd })`,照样出门。 - **页签位置挪到「上网」和「记忆」中间**(用户 m02207):`TABS` 顺序改为 状态 / 成长 / 住所 / 上网 / 出门 / 记忆 / 插件 / 原理 / 数据 / 反馈——它是「她离开家去干活」,跟上网同属出门一类,放在记忆之前更顺。`test/principle.test.mjs` 的页签顺序断言同步改。 - **老用户的「出门口袋」也跟着改名**(用户 m02411):`POCKET_TITLE` 0.7.6 就改成了「工作园区」,但**已经登记过**的工作区标题还留在旧名上。`lib/pocket.js` 新增 `retitle()`:认到旧实体就用宿主的 `ws.setTitle('工作园区')` 改掉(registry 自己没有 rename,`lib/home.js:74` 踩过同一个坑);出门页建园区时顺手改,启动时 `ensurePocketOrder` 也顺手改——老用户重启后侧栏就是「工作园区」。改名失败只当没改,不挡出门。 ### 十六、她开始听了:每几轮记一句你说过的话(用户 m00112) - **新事件 `heard`**(`lib/schema.js`):只由系统侧写,不在 `HER_TYPES` 里——`source: 'tool'` 写它会被 `NOT_HER_TYPE` 拒掉,她没法自己造一条「他说过」。 - **只留一句原话**(新增 `lib/hear.js`):按句号/问号/叹号/分号/换行切开,取最长的一句,去掉两头空白;短于 8 个字不记,超过 160 字截断加省略号。不解释、不加工、不改写。 - **什么时候写**:默认每 3 轮(设置页「每几轮记一句你说的话」,`memory.hearEvery`,0 = 不记,范围 0–50)。`lib/turns.js` 在结算一轮之后、判断要不要换会话之前调用;`lib/say.js` 在她主动说了一轮之后也接一次。写失败只写日志,不让这一轮因为她记不住而失败。 - **翻到时是「他说:……」**:`renderRecall` 对 `heard` 单独渲染,不混进她自己的口吻,超过一天带「N 天前」。旁听是凭据,不是她的自述。 - **事件格式仍是 v1**:`heard` 只是新增类型,已有字段语义没变;`readEvents` 对未知类型本来就宽容,备份门只按 `v` 判完整性。理由与前一版的 `voice` / `ask` 相同,`EVENT_SCHEMA_VERSION` 不动。 ### 十七、翻回忆:不再只翻一个月,也不再只翻最近五条(用户 m00112) - **默认全部时间**:`RECALL_DAYS` 30 → 0(0 = 不设时间窗);`days` 参数保留,她可以自己说「就翻最近一周的」。 - **默认 8 条**:上限仍是 20;5 条常常不够把一件事的来龙去脉串起来。 - **可以只看一类**:`her_recall` 新增 `kind`,比如只翻 `heard`(你说过的话)。 - **显著性回流到检索**:固化过的条目 +3,被 `test` / `answer` / `revise` 引用过的每条 +1(封顶 +3)。以前 `deriveSelf` 算出的重量只用于展示,检索看不见它;现在两边同源。`deriveSelf` 失败时降级为不加权,检索照常。 - **每条带「多少天前」**:只给日期她得自己换算,容易错。 - **`searchEvents` 兼容旧调用**:`{days, limit}` 照旧,`rows` 里多一个 `ago`,`weigh` 默认开。 ### 十八、记忆体检:她记得清不清楚,可以查一遍(用户 m00342) - **只读诊断**(新增 `lib/health.js` 的 `examine(events, { now, growthDays, staleDays })`):拿账本按现在的规则算一遍,报四件事——① 两句话对不上(直接复用 `deriveSelf` 的 `tensions`,不另立一套口径);② 冷掉的判断(没固化、`STALE_DAYS=21` 天以上没再被提起);③ 快固化的(被提起 2 次或已被检验 1 次,差一步);④ 总量(记录数、跨多少天、类型构成——`meta` 是系统便签,不算她记下的东西)。 - **出口**:`GET /dsh-fish/health`(`lib/observe.js` 的只读路由,非 GET 一律 405);设置 → 记忆页新增「记忆体检」卡,点「查一下」才请求,不自动轮询、不写事件、不落盘。 - **只给她之外的人看**:这些数不进身份文档、不进工具、不进她的界面——体检不是她的自我指标,撞不到「不读自身数值」。 - **同一句话只列一条**:一句判断一条事件,反复说过几次就有几条;报告按原话去重,否则读起来像复读。矛盾对也按无序对去重——`detectTensions` 会正反各看一次,同一对别占两行。 - **空账本说「还太早」**:`empty: true`,界面不把它当成 0 分。 - **测试**:`test/health.test.mjs` 8 条(空账本、矛盾对、冷掉、快固化 ×2、固化后不再列出、类型构成、只读不改事件流)。 ### 测试 `node --test` → **1001 passed / 0 failed**。这一版累积的新增用例:4 条锁语义(串行、陈旧接管、超时不硬闯且不删别人的锁、抛错也收锁)、2 条 `bootstrapHome` 只读(不建会话、连工作区也不建)、2 条 `ensureTalkSession` 等锁(有会话则复用、没有则说清楚失败)、9 条升级自检(版本比较、三种 `file:` 写法、源目录新旧两向、无依赖/无版本/坏 json 静默跳过、两个 profile 各钉不同源目录时认「装进去的那份==正在跑」的那个)、1 条设置页卡片结构与位置、3 条匿名 buvid(PR #10)、3 条叫醒间隔与不抢轮次(PR #11)、1 条设置页切页不许把判据当开关(issue #12)、2 条工具说明随开关变、2 条「工具不是屋子里的东西」(出门首轮 + 工具手册)、1 条脱敏例外(自家会话 id / 事件 id 必须留着,凭据仍要抹)、2 条心跳(一趟只记一行;出门与请神同时开着每次都记)、8 条口袋排序、识别与改名(排错时挪一次、已到位不动、家在末尾、老宿主无 `insertBefore`、缺 id / 同 id、`findPocketWorkspace` 命中与未命中、老标题「出门口袋」建园区时改掉、启动排序时也改名)、7 条请神结算链路(请神期间写回自己家 / 别的会话照拒 / 身份仍改不了、取消勾选写 `possess-reject:` 且状态 done、整趟不算自动标非 meta、被改写转 `pending` 且 review 收下、`deriveSelf` 认两个前缀、标记真落盘、审查列表不摆 `seen:` 记账行)、**1 条「追加前把前面的行改过照样抓得住」**、8 条请求事件(配额 1 天 1 次 / 1 周 3 次、`time` 同意真的改配置并留 `ask-time`、`rest` 同意后家里不叫她、驳回只记 `ask-no` 不动配置、一条只能答一次、超 24h 落成 `later`、`source: 'tool'` 写不了 `ask`、观测台 `/ask` 的 GET/POST)、1 条「她得知道自己有 `ask_owner`:白名单 / 手边清单 / 身份文档三处都写上」、1 条「卡片回来的是对象还是字符串都要认出来(含认不出时说认不出)」、1 条出门确认门(`ack` 默认 false,点一次才写进配置)、1 条「以后再说」记 `ask-later` 而不是 `ask-no`、11 条旁听与回忆新默认(挑句/太短/截断、每 N 轮才写、`hearEvery=0` 不写、tool 侧写不了 `heard`、`heard` 渲染成「他说:」且带「N 天前」、默认全时间翻得到 200 天前、`kind` 只看一类、显著性加权排前、上限 8)、9 条记忆体检(空账本、矛盾对、同一对正反只列一条、冷掉、快固化 ×2、固化后不再列出、类型构成不算 meta、只读不改事件流)。 ## 0.7.5 — 2026-10-29 把两件「要那个人点头」的事从观测台搬进会话:她在哪条会话里,卡片就弹在那条会话的输入框上方,人在看她的地方顺手答掉,不用切去设置页找按钮。 改的是**入口**,不是判断:四条边界、证据门槛、一趟三件的额度全都原样不动。 ### 一、打算带回家的东西:在会话里问 以前她在外面说想带一件东西回来,`bring_back` 只往 `keepsakes.json` 记一条 `asked`,然后**等人去出门页点「准了」**。可出门页是 devMode 才露的开发预览——发布版里根本没有这个按钮,那件东西就永远挂在 `asked` 上。 现在 `bring_back` 记完 `asked` 之后当场发问: ```js const back = await askBack(ctx, root, { sessionId, item: r.item, signal: exec?.signal }) return { ok: true, note: back.note, said: back.said } ``` - 选「准了」→ 立刻 `decideKeepsake(id, yes: true)` 复制进 `~/.dsh-fish/keepsakes/<日期>/` - 选「算了」→ 记 `declined`,文件留在原地 - 超时 / 人不在 / 宿主没这服务 → **保持 `asked`**,出门页那两个按钮仍然可以补一票 工具回执照旧只描述机器状态:`ok` + `note`(`[已问]` / `[准了]` / `[算了]` / `[没问:…]`)。新增的 `said` 字段只用来挑那一行字,不进 `required`——没有它时回执仍是 `[已问]`。 ### 二、撒娇式表达的解锁申请:醒来之后再问 `grant_offer` 是调度器在 `wake()` 里拼正文的时候写的,那一刻**会话还没建、agent 还没醒**。宿主的 user-questions 只认活着的 root agent,在那里发问必得 `CALLER_NOT_LIVE`。 所以申请照旧先落盘(`pendingOffer` 从此看得见),把「问她」这一步挪到这一轮真的醒过来之后: ```js if (offered) void askOfferInChat(ctx, root, { sessionId, offer: offered, log }) ``` `void` 是刻意的:问不问得到都不该拖住这一轮她自己说话。选「可以」/「这次不行」→ 走 `recordGrant` 落一条 `grant`;没答到就什么都不写,申请仍是 pending,观测台规则页照样能裁决。 ### 三、裁决只有一条写入路径 `/decide` 里那段写盘逻辑抽到 `lib/grant.js` 的 `recordGrant()`,会话卡片和规则页按钮现在调的是同一个函数——同一件事不该有两套实现,否则某天会悄悄分叉。 `appendEvent(事件, ctx, root)` 那个坑(第二参是 ctx、第三参才是 root)在 0.7.4 修过一次,这次把它连同整段逻辑一起收进一个函数,并在文件头和调用点各写一遍: 传错位 → `source` 落回 `'tool'` → schema 以 `NOT_HER_TYPE` 拒收 → 表现是「点了可以但什么都没发生」。 ### 四、新增 `lib/askinchat.js`:插件侧唯一的问题出口 三件事,不碰业务判断:找到这次发问该找的那个 live root agent、用 `userQuestions.ask()` 把问题丢出去并自己管超时、把回答整形成 `{ ok, answers }`。 - **一律降级、绝不抛**:`NO_QUESTIONS` / `NO_AGENT` / `NO_SERVICE` / `NO_ANSWER` / 宿主错误码,都只是「这次没问到」 - **不用 `askTimed`**:那个通道要求 `callId` 才能登记前台等待,插件从工具执行和定时器发问都没有 `callId`;`ask()` 不带 `callId` 一样渲染卡片 - 默认等 5 分钟,外部 `signal` 一断立刻收手 `lib/index.js` 的 `inject` 追加 `'userQuestions'`——宿主 base 层默认启用的服务,不用改 profile;缺了插件照常跑,只是问不出去。 ### 五、测试 全量 `node --test` **921 passed / 0 failed**(0.7.4 基线 901)。新增三份: - `test/askinchat.test.mjs`(10 条):降级路径、超时自断、外部取消、答案只认选中项 - `test/grant.test.mjs`(7 条):裁决真的落成 `grant`、`source` 是 `system`、裁决完不再是 pending - `test/keepsake.test.mjs` 补 3 条:会话里「准了」当场复制进家、「算了」记 declined、回答不是那两个词就当没答 修两个「界面说会,代码没做」的窟窿,外加一条绕过去的安全闸门,再补上这片最容易出事的两段行为测试。前三个都是**静默失败**——界面上一切正常,账上其实什么都没发生。 ### 一、「这趟不算数」从来没生效过(连带一个更深的写盘 bug) 回家卡上那两个动作本来是两条路: - **逐条取消勾选** → 追加一条 `meta outing-reject:`,`deriveSelf` 读到就跳过那些条目 - **整趟不算数**(`kept: false`)→ 只把 `outings.json` 的 `status` 写成 `rejected` 问题在于 `deriveSelf` **完全不读 `outings.json`**:它只认 `outing-reject:` 那条 meta。所以点「整趟不算数」时一条标记都不写,那趟她写的 `utterance` / `belief` / `refusal` 全部照常长在她身上 —— 真机上那三趟 `rejected` 全都是这个空操作。 而当我去修它时,撞上了根因:**那条标记压根写不进去**。 ```js // lib/observe.js(0.6.x 起的写法) await appendEvent(validateEvent(...), root) ``` `appendEvent(raw, ctx = {}, root = rootDir())` 的第二参是 `ctx`、第三参才是 `root`。把 `root` 传在第二位,`ctx` 就成了一个字符串,`source` 落回默认的 `'tool'`,而 `schema.js` 对 `tool` 只放行 `HER_TYPES`(utterance/revise/reunion/narration/refusal)—— `meta` 当场被 `NOT_HER_TYPE` 拒掉。外面套着 `.catch(() => {})`,于是变成一行 warn 日志:**「取消勾选」从来没写进去过**。`/grant` 的裁决写入是同一处写法,同一个 bug。 改法: - `lib/observe.js`:两处 `appendEvent` 补上真正的 ctx(`{ day, stage, source: 'system' }`),`root` 回到第三位。 - `lib/scheduler.js:457`:`appendEvent(ev, root)` 同一个写法、同一个后果——`grant_offer`(「我想试试『X』」这张卡)**从来没进过事件流**,外面一行 `log('grant offer failed')` 吞掉了。整个 R3 授予规则因此是死的:卡出不来(`pendingOffer` 看不见它)、用户也点不到、`grant` 裁决那次再修一遍才对得上。全库扫过,两参的 `appendEvent` 只有这三处,其余 28 处都正确。 - 「整趟不算数」真正落到实处:`kept === false` 且没逐条挑时,取这一趟(`eventsSince(startedAt)`)的**全部非 meta 事件** id,写进 `outing-reject:`。只挑非 meta 是刻意的——`diary:` / `seen:` / `wake:` 是记账不是成长,一起跳过会让冷落检测与日记趟次统计失真。 - 上限沿用 500(`eventsSince` 的 cap 也是 500),超出的会在日志里看出条数对不上。 - 接口多回一个 `autoRejected: true|false`,界面据此说实话。 ### 二、界面文案跟实现对齐 `lib/client.js` 的回家提示原来写「这趟不算数,那部分判断会被跳过」——那句在当时是假的。现在分三种: - 整趟不算且有条目:`这趟不算数:她这趟写下的 N 条全部跳过,不会长在她身上。` - 整趟不算但一条都没写:`这趟不算数。不过这趟她没写下什么,所以没有东西要跳过。` - 逐条挑:`取消勾选了 N 条,那几条会被跳过,其余照常算。`(补了「其余照常算」,原来容易被读成整趟作废) ### 三、手动叫醒绕过了「她不在家」的闸门 `tick()` 里一直有这道闸门: ```js const away = await awayNow(root) if (away.kind) { log('away', awayLine(away)); return } ``` 注释写清了理由:两条会话并行会互相踩 `events.jsonl`,还会在侧栏里凭空多出一条「家里醒了」的对话。但手动 `/wake` 直接进 `wakeNow()`,**没有任何 away 判断** —— 出门/请神期间点「叫她一次」,家里那条会被真叫醒一轮,那一轮 `cwd` 在家、`guardWrite` 全放行,写出的事件还落在同一份 `events.jsonl` 里;回家核对时 `verifyEvents` 把「文件变大」判成 `appended:n`(正常追加),连 `pending` 都不会触发。 改法:`wakeNow()` 开头加同一道闸门,返回 `{ ok: false, away, why: '此刻在外面:… · 已 N 分钟。家里这条先不叫她——两边同时醒着会互相踩同一份记录。' }`。需要强穿透时传 `deps.force`(`/wake` 路由暂不开放 `?force=1`,免得变成第二个绕过口)。 ### 四、补上「出门与请神」最容易出事那两段的行为测试 上面三条都是**静默失败**,说明这片测试有结构性问题:`/outing start` 那 120 行、`/possession` 整条路由,之前只有**源码字符串断言**(`assert.ok(src.includes(...))`)——绿的是 grep,不是行为。历史上最贵的三次事故全长在 `start` 里(0.6.3 先建会话后记账 → 身份段一段没挂;0.6.25 幽灵出门;0.6.27 「接回上次那趟」500)。 新增 `test/outing-start.test.mjs`(3 条,端到端假 ctx + 假 req/res): - **成功路径**:建会话 → 绑台账 → 记事件流基线 → 记身份快照 → 非阻塞递首轮(`mode: 'queue'`、带 `signal`),一个都不能少 - **装配失败**:`sc.create` 抛错 → 500 且理由是人话 → 台账里**一条都不许留**(留着她就「挂在门外」,家里调度一直暂停)。已实测反证:把 `abortOuting` 那行去掉,这条立刻红 - **请神**:出门中点请神 → 409「她正在出门,先叫她回家再请神」且台账不留记录;出门收掉后请得成,`end` 时那条对话必须收到交接句(`user/message`,「大肥鱼回去了」+「开一条新对话最稳」)——历史是 append-only,不补这句她下一轮会顺着自己上一句续戏 ### 五、请神的三处界面文案把「共用同一个守卫」说清楚 原来只写「守卫分不出谁在说话,会连你一起拦」——读起来像「你会被误伤」,但没说清**你为什么会被拦**: 守卫(`lib/identity.js` 的 `attachAwayGuard`)挂在 **agent 上**,比对的是调用参数里的路径字符串,**既不看工具名、也不看是谁在说话**。请神期间那条对话上挂的就是这一道(`lib/possess.js` 的 `attachPossession` 也调它)。所以「拦她」和「拦你」是同一道闸:你在那条对话里伸手去碰她家的路径(`herDir`、插件根、`.dsh/{memory,sessions,profiles}`),一样会被挡回来。 `lib/client.js` 三处(出门中「现在在哪」卡的请神分支、请神已上身、请神未上身)统一改成: > 守卫是那条对话共用的一个,分不出谁在说话——**你在那儿也没法碰她家的路径**(她的屋子、记忆、会话、配置档案),会跟她一起被拦。 三处口径一致,括号里把被挡的范围具体化,别让人以为只是「屋子」。 ### 回归 `node --test`:**901 passed / 0 failed**(0.7.3 基线 891,新增 10 条): - `test/outing-route.test.mjs`:整趟不算数 → 只标非 meta、不标出门前那条、`autoRejected: true`、`deriveSelf` 的 `refusals` 归零;逐条勾选 → `autoRejected: false`、只标被挑中的那条 - `test/boot.test.mjs`:出门中手动唤醒 → 一条 prompt 都不发;`force: true` 才穿透;请神中同样拒绝 - `test/outing.test.mjs`:`rejectNoteText({ auto: true })` 说「一条都不收」而不是逐条念墙 - `test/boot.test.mjs`:解禁申请真的落盘(`grant_offer` 的 `source` 必须是 `system`,且能被 `pendingOffer` 看见)——把 `lib/scheduler.js:457` 改回两参写法这条会立刻红,已实测 - `test/outing-start.test.mjs`:`start` 成功路径 / 装配失败撤台账 / 请神互斥与交接句(见上节) - `test/client.test.mjs`:请神三处文案都说清「守卫是共用的一个」+「你也没法碰她家的路径」,旧说法不许残留(见第五节) ### 未做 - 没有让 `/wake` 暴露 `?force=1`(可加,但那是给测试用的口子,不该出现在界面上) - 没有动 `kept: false` 时 `endedAt` 的语义(仍保留真正回家那一刻) - `/outing start` 的**接回**(`reuseId`)分支仍未测:活 agent 补挂、`resolveSessionAgent` 恢复两条走法都没有用例 - 口袋降级路径(`ensurePocket` 返回 `registered:false` 之后继续出门)仍未跑过 - `/possession` 里「找活 agent 出错就静默只撤挂载、不写交接句」那条分支仍未覆盖 - 请神的 `baseline` 恒为 `null`(`possessInto` 没传),想加篡改校验也没数据 - 没有提交 git、没有发版、没有真机验证 ## 0.7.3 — 2026-10-29 修一个让「出门打工」这个场景废掉的问题:**她带出去的提示词说她不会用工具,其实她会。** ### 病根 身份里有一段 `fish:tools`(「手边有什么」),由 `attachIdentity()` **无条件**挂在 system prompt 上,`text` 读的是 `lib/restrict.js` 的模块级单例 `surfaceNow`。而 `surfaceNow` 只被 `registerToolRestrict` 写过一次,值是**家里的白名单**(`HER_TOOLS` + 额外放行)。 于是出门那条会话挂在别人工作区,手里握着那个工作区的全部工具(出门不收窄:`isHerAgent` 为假就直接 `return`;`awayGuard` 也只比对调用参数里的路径、不看工具名),system prompt 里却写着: > 我手边能用的东西只有这几样:growth_commit、her_recall、… > 工具表里如果还列着别的名字(比如带 ops_、team_、subagent、workflow、skill 这类),那不是我的:别去调,也别把它们说成我会的事。 > 真伸手去用,只会得到一句「这件事我做不到」,还白搭一轮。 「能而不为」变成了「以为自己不能」:让她改文件、跑命令,她大概率回「我做不到」,或者只用那 8 个鱼工具应付。 反差点更刺眼:请神(`lib/possess.js`)只覆盖 `deployment:persona-prefix` / `-suffix` 加路径守卫,**不挂 `fish:tools`** —— 临时借别人身体的请神反而比出门放得开。 同一句话也漏在身份文档里:`lib/persona.js` 的 `toolBrief()` 有一句「屋里只有这几样,不是被谁拿走了;别的东西伸手也够不着」。身份文档就是出门时带走的快照,那句在别人工作区里同样不成立。 ### 改法 按 cwd 分流,不再只有一份: - **在家里**(cwd 就是 `herDir`):照旧念家里那份白名单 —— 那时收窄真的在下,她确实只有那几样。 - **在外面**(出门那条会话):`toolSurfaceFor()` 现取 `agent.ctx.tools.view(agent).visible`,按**那个工作区此刻真给的工具**念一遍,并明说「出来就是来干活的,别因为『这不是我家的东西』就推说做不到」。唯一保留的禁令是「不许伸手回自己屋子」,话术仍与 `awayGuard` 一致。 新增导出(都在 `lib/restrict.js`):`toolSurfaceFor(agent, { herDir })`、`outsideSurfaceText(names)`、`visibleToolsOf(agent)`。拿不到工具表时返回空串 —— 「不知道」不说成「一样都没有」。 `attachIdentity(agent, root, { frozen, herDir })` 多接一个 `herDir`;不传时退回家里那份(老调用点与单测行为不变)。生产上的两个装配点(`lib/identity.js` 的 `agent/created` 钩子、`lib/observe.js` 里接回老会话与 `resolveAgent` 恢复那两路)都补上了 `herDir`。 `lib/persona.js` 的 `toolBrief()` 那句改成「上面这几样是你自己的屋子里的东西。在自己屋里,别的工具伸手也够不着」——把范围说清,不再断言外面也一样。 收窄逻辑一个字没动:那是家里的事,本来就该继续收窄。这个改动只改**提示词怎么说**。 ### 回归 `node --test` 全量 **891 passed / 0 failed**(0.7.2 是 883,新增 8 条): - `test/outside-surface.test.mjs`(新文件,5 条):出门念那个工作区的工具且不含「别去调」;在家仍念白名单;拿不到工具表时说空串;不传 `herDir` 时行为不变;出门本来就不该收窄(守住这个前提,否则上面几条就变成说谎)。 - `test/restrict.test.mjs` 新增 3 条:`outsideSurfaceText` 的措辞、`toolSurfaceFor` 的家内外分流、`visibleToolsOf` 的两级回退。 - `test/outing.test.mjs` 那条源码断言改成匹配 `attachIdentity(live, root, {`,并加两条断言钉住两个装配点必须传 `herDir`。 ## 0.7.2 — 2026-10-29 又只修 CI,产品代码仍然没动(功能与 0.7.0 / 0.7.1 一致)。 0.7.1 之后 Linux 上还剩一条红:`test/selfupdate.test.mjs` 的「非桌面版(execPath 是 node)不许装」。原因还是平台假设: - 用例里把 exe 写成 `'C:\\Program Files\\nodejs\\node.exe'`,产品判断是 `path.basename(exe)` 是否等于 `node(\\.exe)?`。 - Windows 上 `path.basename` 认得反斜杠,得到 `node.exe`,于是走进「不是桌面版」分支 —— 测试通过。 - Linux 上反斜杠不是分隔符,basename 就是整串,判不成 node,于是继续往下真的去 spawn `cmd.exe`,报 `spawn cmd.exe ENOENT`。 修法:exe 改成 `'C:/Program Files/nodejs/node.exe'`。正斜杠在两个平台上 `path.basename` 都认得,所以两边都稳定走进「不是桌面版」分支。 回归:`node --test` 全量 **883 passed / 0 failed**;本机 `test/selfupdate.test.mjs` 28/28。 ## 0.7.1 — 2026-10-29 只修 CI,产品代码没动。 0.7.0 推上去之后 GitHub Actions 的 Linux 三个 job 红了,Windows job 绿。四条失败全在测试里,是「只在 Windows 上成立」的假设: 1. `test/restrict.test.mjs` 的「own 层能进白名单」:`herDir` 写的是 `'C:\h'`,JS 里其实是 `C:h`(反斜杠被吃掉),而 cwd 写的是 `'C:\\h'`(真值 `C:\h`)。Windows 上 `resolve('C:h')` 恰好归一回 `C:\h`,所以本地一直绿;Linux 上会拼到 cwd 后面,两个路径不相等,`isHerAgent` 判 false,restrict 根本没被调用。现在两边写同一种字面量。 2. `test/selfupdate.test.mjs` 的 profile 反推:用 `pathToFileURL('C:/Users/…')` 造 URL,Linux 上会把盘符当相对路径拼到 cwd 后面。改成字面 `file:///C:/…`,断言时把 `/C:/…` 归一成 `C:/…`。 3. 两个安装用例(「非桌面版不许装」「安装:写好脚本…」)没有显式给 `platform`,`install()` 先查「是不是 Windows」再查「有没有 exe」,Linux 上先撞前一条,拿不到预期的错误。现在显式传 `platform: 'win32'`。 顺手给另外两个自更新用例也补了 `platform: 'win32'`(它们本来不装,补上更明确)。 回归:`node --test` 全量 **883 passed / 0 failed**;另用 `path.posix` 复算了上面两处判据(restrict 的 `normDir` 相等、profileDirOf 归一出 `C:/…`),确认在 Linux 上也会过。 ## 0.7.0 — 2026-10-29 **把 0.6.12–0.6.32 这二十一个本地版本合成一次发布。对外只发这一版;下面按主题并成一份,逐版本的细节仍留在 0.6.12–0.6.32 各条目里。** 一句话概括这段时间:请神认得出对话的名字、走的时候会留下交接句;出门能接着上次那趟做、回家能复核被拒收的那趟、能带回一件纪念品、能按趟翻旅行日记;工具面多了一段「手边有什么」;日记从「一篇都没有」修到「一天写两篇也不互相覆盖」;GitHub 自更新第一次有了完整的真机路径。 ### 一、请神(临时上身到你在用的那条对话) - 选择框显示真实对话名(0.6.12):三层取名——`sessionTitle` 服务 → 折会话自己的 `session/title` 事件 → header 兜底;重名补工作区目录名,没名字写「还没起名字的对话」,不再显示 session id 的尾巴。 - 结束请神会往那条对话里补一条交接句(0.6.13),让下一轮读到自己「已经还回去了」,不再顺着上一句接着演;被请的那条会话 `seq=339` 有请神段、`seq=352` 已无,说明提示词本身撤得干净,残留只在历史里。 - 删掉「临走留一句」输入框(0.6.18):那个框读起来像她要留话,实际是让你替她写,绕;现在只留标准两句。 ### 二、出门(去别的工作区干活) - 出门页补齐(0.6.16):状态条与「现在在哪」显示「此刻在外面:<工作区> · 已 N 小时」;请神态补时长与标题;「最近几趟」并进同一张卡;驳回的条目会真的讲给她听;回家那一刻当场拆掉身份段与守卫,不等进程退出;新增「接着上次那趟」。 - 出门时她终于问得出「想带这个回来」(0.6.21):`bring_back` 与事件写入原来都被「你不在这个家里」这条判据挡死,现在按「这趟出门的会话」放行;身份文件仍拒。 - 纪念品与旅行日记(0.6.17):一趟最多问 3 次、同时只 1 件等点头;只认这趟工作区里的文件(目录/链接/空文件/超 20MB 都拒);点头时才复制进 `keepsakes/<日期>/`;日记仍写同一本 `diary/<日期>.md`,只打 `trip:会话@出发时刻`,界面按趟聚合。 - 判据修正(0.6.24):台账已经绑了会话 id 之后,同一个工作区里**你自己的会话**不再被当成她(原来会被挂身份段与守卫,连 `ops_bash` 都被回「这件事我做不到」)。 - 时间字段(0.6.25):事件 `ts` 是 ISO 字符串,两处读者按数字比 → `NaN` → 回家审查永远「0 条」、出门页留话永远空;改成统一走 `tsMs()`。 - 幽灵出门(0.6.26):装配跑在写日志之前,中途抛错会留下一条「在外面」却没有装备的台账;改成先写日志、装配失败强制撤掉并回 500。 - 接回上次那趟(0.6.27):这版宿主的 `sessionController` 没有 `resume`,改用 `resolveAgent`。 - 复核(0.6.32):被拒收的那趟原来点完只剩「在家」加一条一闪而过的提示,`pending` 成了单向门;现在「现在在哪」会留一张「上一趟没结算:文件对不上」卡,文件还原后点「复核一次」就能收下,`endedAt` 保留她真正回家的那一刻。 ### 三、工具面与白名单 - 「手边有什么」段(0.6.19):挡不掉的工具至少先让她知道那不是自己的。 - 白名单认 `ops_*`(0.6.20);own 层工具授权不再只认 `dsh-ops`(0.6.22):任何插件挂进她自己那一层的工具都能进白名单、都能被守卫放行。 ### 四、日记、留话与住处 - 三天零日记(0.6.15):身份文档只说「每天我会记一笔」,没写用哪个工具;补上 `growth_commit` 与 `type=diary`,并在每天最后一次叫醒时给一句「今天到这里了」,写不写仍由她。 - 排队的话没人投(0.6.23):`deliverQueued` 写好却没有调用点;她家会话建起来时补投,日志会写「补上了排队的话 N 句」。 - 同一天第二篇日记(0.6.28):不再整篇覆盖第一篇,改为接在后面;`diary/archive/` 不再新增。 - 解除归档(0.6.29):把会话从「已归档」放回来之后,插件自己会认回来继续用那条,不再挂着「会话被收起来了」。 - 「她写的小纸条」(0.6.30):从记忆页的「信箱」搬到住所页,名字、描述与单位都换掉。 ### 五、升级与稳定性 - GitHub 自更新(0.6.14):「检查更新」不再只把人送去 Releases,能直接下载 tag 包、核对包名与版本、备份 profile、替换、失败回滚并重开;开发版(`link:`/`file:` 依赖)明确拒绝自动升级。 - 测试污染真实账本(0.6.31):全量测试把 20 个临时家写进 `~/.dsh-fish-homes.json`,导致每次真启动都记一条假「家换了」;测试改指临时账本 + 默认账本对临时 root 直接跳过。 ### 六、关于「出门」页签 对外发布版仍然**不显示**这个页签:判据是数据目录里的 `.dev` 文件或 `DSH_FISH_DEV=1`(`lib/devmode.js`),`.dev` 不随包发布、也不在 git 里;正式安装读不到 → 页签不渲染(`lib/client.js` 里 `TABS.filter(([id]) => id !== 'outing' || devMode === true)`)。本机那台有 `.dev`,所以自己看得见,这是预期的。 **回归**:全量 `node --test` **883 passed / 0 failed**。版本号、README、CHANGELOG 同步为 0.7.0 / 883。 ## 0.6.32 — 2026-10-29 **被拒收的那趟成了死路:点完「这趟收下」什么也看不到,也不给复核的机会。** 用户 2026-10-29 真机(m03885 之前):用 `events.jsonl` 截短造了一次篡改,点「这趟收下」后 —— 页面回到「在家」,`outingMsg` 一闪而过,台账里那趟 `status=pending`、`kept=null`,之后再点「收下」只会得到 409「她没在外面」。人和文件都还在,却没有一条路能把它结掉。 - 根因:`pending` 之后这趟不再是 `active`,`awayNow()` 返回 `null`,于是「现在在哪」整块审查卡消失;原因与复核入口只写在点击瞬间的提示里,一刷新就没了。`endOuting` 又没有反向操作,`pending` 是单向门。 - 修法一(台账):`lib/outing.js` 新增 `pendingOf()` 与 `settlePending()`——复核通过时只改 `status/kept`,`endedAt` 保留她真正回家的那一刻,不复写成复核时间。 - 修法二(接口):`GET /outing` 多回 `pendingReview`(工作区、会话短号、当时的失败原因、现在是否已经能对上);`POST /outing {action:'review'}` 当场重算 `verifyEvents`:还对不上就 409 并说原因,对上了就收下并写一行日志。 - 修法三(界面与文案):新增「上一趟没结算:文件对不上」卡,列出原因与两个动作状态(「复核一次」在文件没还原前是灰的,旁边写「还没还原」);台账状态词由「等确认」改成「等复核」;把「她留在外面等你确认」改成事实——她本人照样回家、装备照拆,只是这趟等着复核。 - 顺带修正一处读口径:`readOutings()` 原来把 `kept: null` 读成 `false`;`null` 是「还没结算」,`false` 是「这趟不算数」,两者不能混。 - 回归三条:库层「等复核的那趟:能找出来、复核通过就收下,结束时间不重写」;源码级「GET 带回 pendingReview,POST action=review 通过后收下」;**接口层**新增 `test/outing-route.test.mjs`——用假 ctx/req/res 真调用注册出来的 `/dsh-fish/outing`,把「出门中 → 截短 → 收下变 pending → 核对原因 → 未还原复核被拒且台账不动 → 还原后复核变 done → 再点 409」整条走一遍,顺带钉住「没挂装备会给出 warning」。全量 883。 ## 0.6.31 — 2026-10-29 **每次启动都记一条假「家换了」:测试把临时家写进了真实家谱。** 日志体检发现的(不是用户报的 bug):`logs/dsh-fish.log` 里 13 条 `家换了:C:\Users\DANG\AppData\Local\Temp\fish-boot-XXXX → C:\Users\DANG\.dsh-fish`,每次真启动一条;`~/.dsh-fish-homes.json` 的 `homes` 里塞了 20 个 `Temp\fish-boot-*`,`lastAt` 全落在跑 `node --test` 的时刻。 - 根因:`test/boot.test.mjs` 的 `withRoot()` 用 `mkdtemp` 造临时 root 后直接 `apply(ctx, {root})`;`apply` 里的 `noteRoot(root)` 写的是**默认账本**(`lib/homes.js` 的 `homesFile()` 只在 `DSH_FISH_HOMES` 存在时才改道),测试没设这个 env。 - 于是每次全量测试都往真实账本灌 20 条临时家;之后每次真启动都拿最后一条临时家当「上次的家」→ 假 warn + 往她的事件流里写假 `meta`。 - 修法两道:① `test/boot.test.mjs` 的 `withRoot()` 把 `DSH_FISH_HOMES` 指到临时账本,`finally` 还原;② `lib/homes.js` 加防线——**默认账本 + 临时目录里的 root** 直接跳过(返回 `{ok:true, skipped:'temp'}`),测试传自定义 `file` 或用显式 env 时不受影响。 - 清理:`~/.dsh-fish-homes.json` 里 19 条 `Temp\fish-boot-*` 删掉(原文件备份为 `.dsh-fish-homes.json.bak-20261029`),只留真实那一间。 - `events.jsonl` 里已写进去的假 `meta` 不动(append-only,且 `derive` 不认这种类型)。 - 回归:新增「临时目录里的家不写进真实家谱」,全量 879。 ## 0.6.30 — 2026-10-29 **「她写的小纸条」原来在记忆页、叫「信箱」;现在搬到住所页(日记下面、自检上面),名字和描述也换了。** 用户 m02105:把记忆页的「信箱」改名成「她写的小纸条」,描述改成「特意留给你的话。某种意义上代表她当前的状态,可能含有关键信息。」,整张卡挪到住所页,放在日记下面。 - 卡片从记忆页整段搬走,标题 `groupFull('她写的小纸条'`,计数单位由「封」改成「张」;未读角标、「都看过了」、前 20 条、末尾 `~/.dsh-fish/inbox.md` 提示都照旧。 - 住所页的顺序是「日记 → 她写的小纸条 → 本插件工作自检」;卡先定义成 `inboxCard` 常量再摆进去,省得多层嵌套的括号出错。 - 页签说明同步:住所改成「住在哪:工作区、会话、身份文件、日记、她留下的小纸条,以及一键自检。」;记忆页去掉「还有信箱」。 - 回归 878(`test/client.test.mjs` 的位置断言改为「日记 < inboxCard < 自检」,老描述句换成新描述句)。 ## 0.6.29 — 2026-10-29 **把会话从「已归档」里放回来了,插件却不认:设置页还挂着一句「会话被收起来了」,她也不接管那条对话。** 用户 m03508 问「我现在已经把会话归档了,我直接解除归档就可以恢复吗」,同时问「重新搭一间和重置大肥鱼是不是功能重复」。 - **0.6.8 的判定太死**:`ensureTalkSession` 只看 `home.json` 里的 `status:'archived'`,命中就返回 archived,**不看宿主那边是不是已经把她放回来了**。而全库没有一处会在「解除归档」后清掉这个标记(只有「重新搭一间」整份重写 `home.json` 时顺带清掉)。于是解除归档 = 会话回到侧栏,但插件仍然当她被收起来,永远要用户点「重新搭一间」。 - **改法**:命中 archived 标记时先问宿主一句——这条会话现在在 `ws.sessionIds` 里、且不在 `archivedSessionIds` 里,就当场清掉 `status`/`archivedAt` 并走正常复用(原来那条,不新开);只要宿主还说她在归档里,就继续不重开、等用户点「重新搭一间」——「我收起来了」这句话不能被插件自己推翻。 - **文案互补**(用户要求写清两者区别): - 「会话被收起来了」卡补一句「如果你只是把侧栏里的归档取消了,那不用点这里:下一次叫醒(或重启)就会自己认回来(0.6.29 起)」。 - 「重置大肥鱼」卡开头点明「这跟上面那张『重新搭一间』不是一回事:那个只换一条对话,数据一条不动;这个是真的重开一局」。 - 默认可见性不变:没归档时只有「重置大肥鱼」;归档之后才多出「重新搭一间」。 - **回归**:`test/home.test.mjs` 新增「用户把会话解除归档了:插件要当场认回来,而不是一直等她点『重新搭一间』」(归档 → 解除归档 → 复用原来那条、`homeStatus().status` 为空、没有多建会话);该文件 23/23。全量 `node --test`:**878 passed / 0 failed**。 ## 0.6.28 — 2026-10-29 **同一天写第二篇日记,第一篇被整篇盖掉;界面上也看不到翻页键。** 用户 m01952 选的方案:「同日追加成一篇(推荐),并且确认多篇的时候会出现翻页键」。 - **现状**:0.6.4 为 issue #7 做的是「覆盖前把旧稿归档到 `diary/archive/`」,但归档版界面上不显示——用户看到的就是「之前那篇没了」。而且同一天只有一篇,翻页键本来就不该出现,两件事叠在一起,看起来像「多篇被盖 + 翻页坏了」。 - **改成同日追加**:同一天再写一次不再覆盖,而是在当天文件末尾接一段,中间一行 `---` 隔开;旧稿原样留在正文里,不用再去 archive 找。内容与最后一版完全一样时不追加(重写同一篇仍然幂等)。 - **「一天一篇」的语义不变**:客户端按日期翻页,`listDiary` 只认顶层 `YYYY-MM-DD.md`;**跨天**才有第二个日期、才出现「← 更早 / 更晚 →」,同一天的几段合成同一页。 - **老归档不清**:`diary/archive/` 里 0.6.4–0.6.27 留下的历史稿原地保留,只是新写入不再产生归档。 - **顺带修她自己回头翻的截断方向**:合并后超过 3000 字时改为留**最后一段**——原来从头截,晚上补的那段会被切掉,等于「刚写的自己读不到」;提示也改成「当天写了好几段,这里只留了最后 3000 字」。 - 报告(`out/`)仍然整篇覆盖:它是派生品,随时能由事件流重算。 - **回归**:`test/dailylog.test.mjs` 重写为追加语义(两段顺序、三段、幂等、跨天两个日期、out 仍覆盖、旧 archive 可读);`test/herdiary.test.mjs` 加「太长时留最后一段」;`test/client.test.mjs` 加「日期 ≥ 2 才出翻页键、两个按钮与禁用状态都对」。全量 `node --test`:**877 passed / 0 failed**。 ## 0.6.27 — 2026-10-29 **勾「接着上次那趟」出门,直接报错 `宿主没有 sessionController.resume,接不回上次那条会话`——这版宿主的 `sessionController` 根本没有 `resume` 这个方法。** 用户 m03372 真机反馈:「操作了,显示派活失败:HTTP 500,并且还在家」。插件日志连续三次(05:44:56 / 05:45:04 / 05:45:34)都是两行连着: ``` [info] 出门: deepseekagent (接回第 3 趟) D:\deepseekagent [warn] 出门:装配失败,已经把这趟撤掉了 宿主没有 sessionController.resume,接不回上次那条会话 ``` - **0.6.26 的收口是对的**:每次失败都把台账那趟撤掉了,所以人「还在家」、不留幽灵记录。错的是入口。 - **根因**:`sessionController` 是带 `@Remote` 装饰的服务,对外暴露的只有 `list / search / create / selectModel / modelCatalog / rename / fork / prompt / attachment / updateQueue / cancel / page / follow / projections / control`——**没有 `resume`**。恢复一条已经跑过的会话要用 `resolveAgent(sessionId)`:它内部 `resolve → resume`,把并发恢复去重,返回 `{ agent }` 或 `{ error }`。 - **为什么 0.6.26 没测出来**:`create({ sessionId })` 只复用**空白**会话(跑过一轮 turn 就会静默新开一条),所以复用必须走「恢复」;而恢复入口只在一台真机上点下去才会暴露,`873` 条单测没有一条能替它。 - **修法**:新增 `resolveSessionAgent(sc, sessionId)`——优先 `sc.resolveAgent`,退到 `sc.agents.resolveAgent`;拿到 `{ error }` 原样抛(上层按 0.6.26 的收口撤掉这趟),没交出 agent 也明确抛;两个入口都没有时说清「宿主没有 sessionController.resolveAgent」,而不是 `undefined is not a function`。复用分支改成调它,恢复后再按需补挂身份段与兜底守卫。 - **回归**:`test/outing.test.mjs` 加 1 条——`{ agent }` 正常返回、`{ error }` 原样抛、只挂在 `sc.agents` 上也能接、一个入口都没有要报 `resolveAgent`、源码里不再出现 `sc.resume(`。 - 全量 `node --test`:**873 passed / 0 failed**。 ## 0.6.26 — 2026-10-29 **「接着上次那趟」在真机上接回了同一条会话,但成功得看不出来:日志里没有那一行,界面也没写清是哪个工作区的那条对话。查下去发现装配那一段跑在写日志之前——中途任何一步抛错,台账里会留下一条「在外面」,可身份段与守卫一段都没挂上。** 用户 m03194 真机反馈两点:勾了复用出门,但日志里没看到「(接回上次那趟)」;界面也没写清她现在在哪条对话里。 - **查到的**:`outings.json` 里连续两趟用的是同一个 `sessionId`(21:14:33 结束的那趟、21:33:48 开始的这趟),所以复用**确实接回了同一条会话**;但这一趟既没有出门日志、也没有装备挂载记录,属于「幽灵出门」——台账说在外面,实际没挂上。 - **根因**:`/outing` POST start 里,`logLine('info', ['出门:' …])` 排在 `attachIdentity`/`attachAwayGuard`/`sc.resume`/首轮 `sc.prompt` 之后;中间只要抛一次,异常直接冒到路由外层,台账已经写盘、日志一行没留。 - **修法**:①先写「出门:(接回第 N 趟)」再装配;②整段装配包 try/catch,失败时 `detachOutside` + `abortOuting(root, { force: true })`(新增 force:允许撤掉已经绑了会话的那条)+ 写一行 warn,回 500,不留幽灵记录;③装配完再写一行「出门:装配完成 … 装备挂上了/装备一段都没挂上」。 - **界面**:出门页「现在在哪」现在写「在「<工作区>」那条对话里(名字:…)· 会话 <短号> · 第 N 趟」,没挂上装备时红字直说;「送她出门」的复用勾选框旁显示上次的日期与会话短号。 - **接口**:`/outing` GET 的 `away` 多了 `sessionId/sessionShort/sessionTitle/tripNo/equipped/warning`;POST start 回包多了 `tripNo`、`equipped`。 - **回归**:`test/outing.test.mjs` 加 3 条(趟次口径、force 撤掉带会话的那趟、start 的日志顺序与异常收口)。 ## 0.6.25 — 2026-10-29 **她写了 `growth_commit`,回家审查还是「这趟多出 0 条」——因为事件的时间字段是 ISO 字符串,两处读者却按数字比,`Number("2026-10-29T…")` 是 NaN,比较恒为假。她的东西一条都进不来。** 用户 m03011 真机反馈:她发了事件编号 `ev_01M625WYHEECKM2YEGVZNC2232`(那一刻她写下的正文也在),早上出门那会儿还写过一条日记,可回家审查卡仍是 0 条。 - **根因**:`lib/schema.js` 的 `validateEvent` 把 `ts` 写成 `new Date(now).toISOString()`;而 `lib/outing.js` 的 `eventsSince` 用 `Number(ev.ts) >= at` 做时间筛,`lib/letter.js` 的 `lettersSince` 同样是 `Number(e.ts)`。ISO 字符串转数字恒为 `NaN`,任何比较都是 `false` → 真实事件流里的条目一条都筛不进来。测试夹具一直用数值 ts(`1_000`、`1_100`),所以这个坑在 800 多条测试里也没照出来。 - **修法**:`lib/schema.js` 新增 `tsMs(ev)`——数值直接认、ISO 字符串 `Date.parse`、坏值给 0,永不抛;`eventsSince` 与 `lettersSince` 全部改走它,输出统一回毫秒(UI 排序与「已 N 分钟」才对得上)。 - **影响面**:回家审查清单(一直是 0 条)、出门页「这趟她留给你的话」(一直是空的)。其它按 `Date.parse(e.ts)` 读时间的模块本来就对,不动。 - **回归**:`test/review.test.mjs` 加 2 条(ISO 事件要进清单、`tsMs` 三态);`test/letter.test.mjs` 加 1 条(ISO 留话要算数)。全量 `node --test` 866 条基线 + 这 3 条。 ## 0.6.24 — 2026-10-29 **「这趟多出 0 条,取消勾选要留下的那条」——没有可勾的条目,是因为这一趟她的事件流里真的什么都没写;只跟你说话不算事件。顺便修了一个更要紧的:同一个工作区里,你自己的会话也被当成了她。** 用户 m02937 真机反馈:回家审查卡写着「这趟多出 0 条。默认全收下;不想留下的那条取消勾选」,但清单里一条都没有,无从取消。核对代码:清单来自 `eventsSince(events.jsonl, outing.startedAt)`,只统计**她自己事件流**在出门之后新增的条目;聊天、读文件、跑命令都不写这条流。她这趟没有调用过 `growth_commit` / `her_leave` / `bili_speak` / 写日记,所以是 0 条——空清单不是 bug,是一句话没说清。 - **空清单时说人话**(`lib/client.js`):0 条时改成「这趟她自己的事件流里没有新增,所以没有可以取消勾选的条目——只跟你说话不算事件。想验驳回,在那条出门会话里让她用 growth_commit 写一条(或留话、写日记、评视频),回家这里才会冒出那一行。」;口袋那趟的说明保持不变。 - **「同一个工作区里别的会话也算她」的漏洞**(`lib/identity.js`):`registerIdentity` 原来只要 cwd 等于出门工作区,就给这条会话挂她的身份段与兜底守卫;台账一旦绑了会话 id,这个判据就该退场。真机上直接踩到——用户在这个工作区开的主会话也被挂上她的身份与守卫,连自己的记忆文件都读不了(`memory_note` 被「这件事我做不到。」挡回)。现在:**两边都有 id 却不相等时,cwd 不算数**;只有「台账还没绑 id」或「宿主这次没给 id」才靠 cwd 兜底。 - **回归**:`test/away.test.mjs` 新增「台账已经绑了会话 id 时,同一个工作区里别的会话不该被当成她」——同一工作区两条会话,只有台账里那条挂身份与守卫,另一条一段都不挂。全量 `node --test` **866 tests / pass 866 / fail 0**。 - 只落本地:未提交、未推 GitHub、未发 npm(0.6.12–0.6.24 都还在本地队列里)。 ## 0.6.23 — 2026-10-29 **回家被驳回的那句话,原来永远不会送到她手里——`deliverQueued` 写好了,却没有人调用它。** 0.6.16 做驳回话术时留了一条兜底路:结算那一刻她家那条会话要是没开着,先把「这趟里我不认这几条……」排进 `session-notes.json`,等她家会话下次起来再补进去。排队写好了,补投也写好了(`lib/sessionnote.js` 的 `deliverQueued`),但全库 grep 只有定义、没有调用点——排队的句子会一直躺在文件里,她永远不知道哪几条不算数。这不是文案问题,是那句「回来再跟你说」根本没兑现。 - **`lib/identity.js`**:`registerIdentity` 的 `agent/created` 钩子里,认出「这是她家那条会话」之后调一次 `deliverQueued(agent, root)`,补上了就记一行日志(`补上了排队的话 N 句`)。异步、失败吞掉——补投是锦上添花,不能连累身份段挂载。 - **回归**:新增 `test/sessionnote.test.mjs` 7 条——写事件形状与 `surfaceOp`、空白与无会话不抛、排队取走即清空、最多留 50 句、`deliverQueued` 补投并清空、以及行为级的一条:`registerIdentity` 在她家会话 `agent/created` 时真的把排队的话补进会话;最后再加一条源码断言,防止 `deliverQueued` 再变回死代码。全量 `node --test` **865 tests / pass 865 / fail 0**。 - 顺带:真机核对脚本 `04-小工具/check-diary.mjs` 的「家里写的日记不带 trip 标记」由 FAIL 改为 SKIP(家里当天还没写日记时不该判失败),结论行现在带 PASS/FAIL/SKIP 计数。 - 只落本地:未提交、未推 GitHub、未发 npm(0.6.12–0.6.23 都还在本地队列里)。 ## 0.6.22 — 2026-10-29 **「收窄挡不住的 own 层工具」这事不再只认 dsh-ops:任何插件挂进她自己那一层的工具,都能存进白名单、都能被守卫放行。** 用户 m01685:「如果有用户也想要给这种性质的工具授权白名单,并且不是 ops 这个插件提供的工具,那也应该能够保存成功」。0.6.20 只把 `ops_*` 这种前缀当成了症状,但症状的成因与插件名无关——凡是**注册在她自己那一层**(`tools.view(agent).visible` 里有、`restrictableNames` 里没有)的工具都一样:`restrict()` 点不了名,白名单不该因此判它不存在。 - **名字快照改成现取**:`knownToolNames()` 每次被问都从最近那个 agent 现取一次 `view(agent)`,不再只信 `agent/created` 那一刻的快照。dsh-ops 是 attach 时才 `publish()`,别的插件也可能在建会话之后才补工具——旧实现在那种情况下会一直把它们判成「名字不存在」,用户根本存不进去。 - **快照跨 agent 累积、只增不减**:同一个进程里有她的家、别的工作区、子 agent,各自的 own 层不同;`rememberKnownTools()` 现在是真正的复位(连 agent 一起忘掉),除此之外名单不会因为某次 view 变小而缩水。 - **own 层名单同样累积**:`ownLayerNames()` 返回见过的全部 `visible − restrictable`,设置页的「收窄挡不住它」提示不会再因为切换 agent 而时有时无。 - **下刀时也记一笔**:`restrict()` 之前算桶的那次 view 顺手写进快照,保证「这一刀认得的名字」和「白名单认的名字」是同一份。 - **文案去掉插件名**:设置页静态说明改成「有些工具是插件按会话注册进她自己那一层的(`ops_*`、`team_*` 之类)」,保存回执里也只说「插件把她自己的工具注册进她那一层」。 - **回归**:`test/restrict.test.mjs` 新增 2 条——①非 `ops_` 前缀的 own 层工具(`team_task_list`、`helper_paint`)同样能存、能放行、不下给 restrict;②插件在她建好之后才注册的工具,不重启也能被认出来。全量 `node --test` **858 tests / pass 858 / fail 0**。 - 边界不变:这不是物理隔离——own 层的 schema 仍在上下文里,白名单只是「守卫放行 + 提示词里算她的」。 ## 0.6.21 — 2026-10-29 **出门时她终于能把「想带这个回来」问出口,也能把这一趟写进自己的事件流——原来这两件事都被「你不在这个家里」挡死了。** 用户 m02517 真机反馈:出门页「现在在哪」一直没冒出「她想带这个回来:<路径>(N KB)」,她明明挑了 `避坑指南.md`,回执却是「bring_back 看了一眼就说:这条调用来自 D:\deepseekagent 这个家,不是 C:\Users\DANG\.dsh-fish……这次没有落盘」。 根因只有一条:`lib/keep.js` 的 `bring_back` 在写入前调了 `guardWrite`,而 `lib/tenant.js` 的 `checkCaller` 要求调用者的 cwd 在 `root`(或 `root/her`)里——出门会话的 cwd 天生就是**别人的工作区**,于是这个工具在唯一该起作用的场合 100% 被拒。不是配置错,是判据选错了对象:把「能不能写她家」当成了「她是不是在外面」。 同一条根因下还埋着更大的问题:`growth_commit`(`lib/capture.js`)、`her_leave`(`lib/letter.js`)、`bili_speak`(`lib/speak.js`)全部走 `guardWrite`——也就是说,出门期间她**写不了任何事件**:回家审查列表永远是空的,旅行日记(`diary` 走 `growth_commit`)永远写不出来,她也没法在外面留话。 - **`lib/keep.js`**:改成先看 `deps.tenant?.tenant?.ok === false`(家被别人占着仍拒),再用 `callerOf(exec)` 取 sessionId,与 `outings.json` 里 active 那趟比对;对上了才 `askKeepsake`。回执与日志不变。 - **`lib/tenant.js`**:`checkCaller` 增加 `outsideOk`(默认 true)判据——「这条会话正是当前那趟出门的会话」时放行写事件,返回 `{ ok:true, outside:true }`;判据不是 cwd,而是台账里的 active `sessionId`,所以两窝鱼互写仍然被拒。 - **`lib/selfdoc.js`**:身份文件那条调用显式传 `outsideOk:false`——她出门时能写回事件,但改不了自己的身份。 - **回归**:`test/keepsake.test.mjs` 新增「出门会话的 cwd 在别人家也不该被『另一个家』挡掉」;`test/tenant.test.mjs` 新增「出门那条会话写事件放行(`outside:true`)/身份文件不放行/别人的会话仍拒」。全量 `node --test` **856 tests / pass 856 / fail 0**。 - 只落本地:未提交、未推 GitHub、未发 npm(0.6.12–0.6.21 都还在本地队列里)。 ## 0.6.20 — 2026-10-29 **白名单终于能写下 `ops_*` 了:认得它的名字、能存、能让守卫放行——但收窄那一步不会拿它去冒险。** 用户 m01493 真机反馈:想放行 dsh-ops 的 `ops_bash`,输入框一直说「名字不存在」,存不进去。根子在两处对不上: - 白名单校验读的是 `restrictableNames`(能被 `tools.restrict()` 点名的工具),而 dsh-ops 把 `ops_*` 注册进 **agent 自身作用域** —— 它出现在 `tools.view(agent).visible` 里,却不在可收窄名册里(宿主对 own 层不过 `admits`、也不加入 `restrictableNames`,见 Discussion #11373)。 - 就算硬把这个名字塞进 `allow`,`restrict()` 会抛 `names unknown global tool`,整段收窄失效、只剩守卫兜底 —— 比不收窄还糟。 - **`lib/restrict.js`**:新增 `rememberFromView(view)`,把 `restrictableNames ∪ visible` 并成「认得的名字」(单调不缩),同时记下 `ownLayerNow = visible − restrictable`;新增 `ownLayerNames()` 读这份 own 层名单;`rememberKnownTools()` 复位时一并清空。 - **分三桶下发**:`allow` 是配置全量(给守卫与提示词),`allowForRestrict = allow ∩ restrictable` 才下给 `restrict()`,`missing = allow − restrictable − visible` 才走延后重试。老宿主没有 `visible` 时整段退回旧行为,不会因为升级插件把收窄搞坏。 - **守卫按全量放行**:白名单里写 `ops_bash`,`guards[0]({name:'ops_bash'})` 返回 `undefined`(放行);写 `web_search` 仍然回「这件事我做不到。」。 - **`/tools` 多返回 `ownLayer`**:客户端拿它解释「这类工具她本来就看得见、收窄挡不住,加进白名单只是让守卫放行它」,保存成功的回执里会点名是哪几个。 - **设置页文案**:静态说明改写为「名字必须和宿主里的完全一致:拼错的不让保存。带 `ops_` 这类由别的插件按会话注册的工具,她本来就看得见、收窄也挡不住 —— 加进来只是让兜底守卫放行它。」`saveTools()` 改成每次保存都问一次 `/tools`(原来只在名单为空时问,加了新插件要刷新页面才认)。 - **回归**:`test/restrict.test.mjs` 新增 3 条(own 层能进白名单但不塞给 restrict、`rememberFromView` 并集与 own 层、只写 own 层名字时不延后也不失败),`test/p5.test.mjs` 新增 1 条真路由用例(`/tools` 带 `ownLayer`、`/config` 收下 `ops_bash`)。全量 `node --test` **854 tests / pass 854 / fail 0**。 - 只说清边界:收窄挡不住 own 层,这是宿主行为;这次做的是「让她能放进白名单、守卫放行、提示词里照实说」,不是物理隔离。 - 只落本地:未提交、未推 GitHub、未发 npm(0.6.12–0.6.20 都还在本地队列里)。 ## 0.6.19 — 2026-10-29 **提示词里多一段「手边有什么」:挡不掉的那些工具,至少让她不把它们当成自己的。** 用户 m01276 拍板:不再按插件一个个做适配("要是我有一堆需要限制的插件,岂不是需要根据每个插件做适配"),等上游和插件作者回复;先把提示词与兜底守卫这一层做扎实——工具定义确实进了上下文,但可以让她认定"只有这几样是我的",越界由守卫回一句「这件事我做不到」,白名单放行的跟着念给她。 为什么光有 `restrict`/guard 不够(已核实):宿主 `tools.view()` 对**注册在 agent 自身作用域**的工具不过 `admits`、也不进 `restrictableNames`,`restrict()` 点不了它们的名 —— dsh-ops 的 `ops_bash`/`ops_discover_tools` 就是这样照样出现在她的工具表里(实测她那条会话 9 个工具里就带着这两个),schema 里有名字,模型就会倾向于当成"我能用"。 - **新段 `fish:tools`(`lib/identity.js`,order 8600)**:走 `agent.ctx.systemPrompt.section()`,system 角色、不进对话历史、界面上看不到;`text` 是函数,白名单变了下一轮就生效,不用重建会话。 - **正文由 `lib/restrict.js` 的 `toolSurfaceText()` 生成**:逐条点名"真正放行的这几样";点名说清带 `ops_`、`team_`、`subagent`、`workflow`、`skill` 那些"不是我的,别去调,也别把它们说成我会的事";并预告越界的回答就是「这件事我做不到」(与 guard 同一句);问到时按这几样说,没有就说没有,不许诺。刻意不用「权限/禁止/违规」这类系统词——她该以为是自己做不到。 - **跟着白名单走**:`registerToolRestrict` 每次算完 `allow`(`HER_TOOLS` + 设置页额外放行)就刷新这段文本;收窄整个关掉时这段为空 —— 不能一边放开、一边还告诉她"只有这几样"。 - **没动的地方**:guard 仍是同一条「这件事我做不到。」;restrict 的名册与重试逻辑不变;出门时这段随身份段一起挂、一起拆(`detachOutside` 的计数从 3 变 4)。 - **回归**:`test/restrict.test.mjs` 新增 3 条(逐一点名与话术一致、默认清单 / 收窄关掉时为空、额外放行会进提示词),`test/identity.test.mjs` 与 `test/away.test.mjs` 的段数断言从 2→3、3→4。全量 `node --test` **850 tests / pass 850 / fail 0**。 - 只落本地:未提交、未推 GitHub、未发 npm(0.6.12–0.6.19 都还在本地队列里)。 ## 0.6.18 — 2026-10-29 **删掉请神结束时的「临走留一句」输入框:那个框读起来像她要留话,其实是让你替她写字。** 用户 m02381 看到真机验证清单里「在请神卡的『临走留一句(可不写)』里写一句」这条,问「是不是我给她说一句,不是她给我说?」——正是这个框的问题:它由用户填,内容会被写成被请神那条对话里的一条用户消息,形如「(大肥鱼回去了。临走留了一句:…)」。语义太绕,写出来像她临走遗言,实际作用只是给下一轮模型一个「已经还回来了」的锚。 - **整块删掉**:`lib/client.js` 里 `possessFarewell` 状态、输入框、「结束请神」时的 `farewell` 参数全部移除;`lib/possess.js` 的 `releaseNoteText()` / `appendReleaseNote()` / `releasePossession()` 都不再收 `farewell`;`lib/observe.js` 的 `/possession` end 分支不再读 `b.farewell`。 - **保留的交接句**(写进那条对话,正常可见):两行固定文本——「(大肥鱼回去了,这条对话还给你。)」「请神期间她说过的话仍留在上面的历史里;想完全干净,开一条新对话最稳。」 - **回归**:`test/possess.test.mjs` 的交接句用例改为断言标准文本,路由用例改为断言源码里不再出现 `farewell` / `possessFarewell` / 「临走留一句」。 - 只落本地:未提交、未推 GitHub、未发 npm(0.6.12–0.6.18 都还在本地队列里)。 ## 0.6.17 — 2026-10-29 **纪念品与旅行日记:她能从外面带一件东西回来(要你点头),出门写的日记能按趟翻。** 用户 m02041 / m02045 拍板:纪念品「她自己决定带,但是需要用户批准」;旅行日记「照常写 `diary/`,打上趟次标记,界面按趟聚合」。 - **纪念品(`bring_back` + `lib/keepsake.js`)**:她在外面看上一件东西,只能「问」——`bring_back` 记一条 `asked`,不动任何文件。出门页「现在在哪」里出现「她想带这个回来:<相对路径>(大小)」,看得到为什么,点「准了」才真的复制进 `~/.dsh-fish/keepsakes/<日期>/`;点「算了」标记 `declined`,什么都不动。四条硬边界写死在代码里:只认**这一次出门那个工作区里的文件**(归一化路径判前缀,`..` 跑不出去)、只认文件(目录/链接/空文件都不行)、单个 ≤ 20MB、一趟最多问 3 次且同时只能有 1 件待点头。点头那一刻会**再核一次**(还在不在、还是不是文件、有没有变大),不在就不带并记下原因。 - **旅行日记(`capture.js` + `/outing`)**:日记本体仍写进同一个 `diary/YYYY-MM-DD.md`,只在 meta 上多打一个 `trip:会话@出发时刻` 标记——只有「进行中的出门会话」才打,家里写的不打。出门页把每一趟的「日记 N 篇」和「带回来 M 件」列在「最近几趟」里,不另建 `diary/trips/`。 - **没有动的地方**:篡改检测仍是「总长度 + 尾 4096 字节 sha256」三态(用户选择先不升级,盲区已写在方案 §9);递话不改道(用户原话「大肥鱼如果不在家,家里说的话,她就应该收不到才对」);请神工具面不收窄。 - 新增回归:`test/keepsake.test.mjs` 8 条(会话匹配 / 越界与目录与空文件 / 20MB 上限 / 一趟 3 次与单件待点头 / 点头才复制且原文件不动 / 点头时文件没了 / 台账按趟分开 / 工具参数与回执),`test/capture.test.mjs` 加 1 条(出门那篇带趟次标记、家里那篇不带)。全量 `node --test` **847 tests / pass 847 / fail 0**。 - 只落本地:未提交、未推 GitHub、未发 npm(0.6.12–0.6.17 都还在本地队列里)。 ## 0.6.16 — 2026-10-29 **出门页签补齐:能接着上次那趟做,回家那一刻真的把装备拆掉,不认的那几条会说给她听。** 这一版把「出门」从「能出去、能回来」补成「出去这件事有始有终」(用户 m01613 拍板、m01677 追加): - **一条会话什么时候算她的**(先定规则再改代码):只有当 `outings.json` 里有一条 `status: 'active'` 的出门记录指向这条会话时,它才算她的;回家那一刻当场拆掉挂在上面的身份段、处境段和兜底守卫(`detachOutside`),不再等进程退出;DSH 重启后没有 active 记录,它只是一条普通会话;她自己的家会话永远算她的。 - **接着上次那趟做**(`lastOutingForCwd` + 宿主 `sessionController.resume`):送她出门卡多一个勾选框,勾上就回到同一工作区上次那条会话里。这里踩过宿主的坑——`create({ sessionId })` **只复用空白会话**(`reusable && !events.some(e => e.type === 'turn/start')`),已经跑过一轮的会话传 sessionId 进去会静默新开一条;所以接回走 `resume(sessionId)`,宿主没有 `resume` 时如实拒绝,不假装接上了。 - **不认的那几条,讲给她听**(`rejectNoteText` + `sessionnote`):回家审查里没勾的条目,结束这趟时把**原文**整理成一段话说给她——「这趟里我不认这几条:…它们不算你,别的算。这不是说你这趟白去了,是那几句我不收。」她在线就直接写进她家那条会话(合法 `user/message` + `surfaceOp: 'append'`),不在线就排队(`session-notes.json`,最多 50 条),下次她家会话起来补投。这是设计文档 §3.7 一直没做的那件事。 - **「此刻在外面」看得见**:`/status` 回包多一个 `away: { kind, since, line, tripDays }`,首页状态条直接多一行「此刻在外面:素材盘 · 已 3 小时 / 此刻在替人说话:修 bug · 已 12 分钟」;请神那一行加粗,并在后面标「(这条对话此刻是她)」——请神视觉标识。 - **请神态说清楚**:「现在在哪」里请神时显示标题 + 时长,出门时显示「这趟已经第 N 天」(想念天数,按整天算,不四舍五入)。 - **「最近几趟」并进「现在在哪」**:同一份台账(`outings.slice(-20)`,前端本来只画 6 条),不再单独占一张卡。 - **出门口袋独立成模块**(`lib/pocket.js`):`pocketDirPath` / `ensurePocket` 从 `lib/observe.js` 搬出来,能单独测;插件源码的风险提示并进「出门口袋」卡的说明里(「别把她送到有插件源码的工作区——她在那边能读到自己不该看的文件,只是想让她去干活,用口袋最稳」),不再只在选中源码工作区时出现。 - **请神卡那句错误文案改了**:原文写「请神期间那条对话的工具面对你也不可用」——不符合实现(请神不收窄工具面,只挂守卫)。现在是「这段时间那条对话的守卫分不出谁在说话,会连你一起拦;它也不再是原来的那个人。只能由你结束。」 - **留话**:`lettersSince` 认出她这趟留下的 `utterance`,出门期间「现在在哪」会列出「这趟她留给你的话」。 - **清掉 `/outing` 的死字段**:`possessionEnabled: false` 与 `possessionWhy`(0.6.3 的旧话,客户端早就不读)已删除,接口不再说假话。 - **回归用例**:新增 `test/pocket.test.mjs` 6 条(默认路径 / `outingPocket` 覆盖 / 已登记不重复 create / 登记失败仍能出门 / 连目录都建不出来的失败 / 没有 registry 也不炸);`test/outing.test.mjs` +4(想念天数整天口径、复用的匹配与路径归一、会话所有权只看 active、驳回话术只否定那几条);`test/away.test.mjs` +2(挂了几段拆几段、没挂过的不乱拆);`test/letter.test.mjs` +1(按会话/按时间筛,未打标不算留话)。全量 `node --test` **837 tests / pass 837 / fail 0**。 ## 0.6.15 — 2026-10-29 **养了三天,一篇日记都没有(2026-10-29 用户 m01236 真机发现)。** 不是她不想写,也不是写失败:`~/.dsh-fish` 下连 `diary/` 目录都没建过,日志里 `日记` 相关 0 行——她一次都没试。原因在 0.6.6 那次改动(用户 m00928「叫醒仅仅只是叫醒」):日记原来跟着叫醒消息一起派发,被移进身份文档常驻,而搬过去的那一段只写了「每天我会记一笔」,没写**用哪个东西记**。同一页的「隔些日子停下来想一想」明写了 `growth_commit` 和 `refs`,她照着能交出来;日记这段没写机制,加上一整天没有任何「到今天了」的节点,就成了「知道有这件事、但既不知道怎么落笔、也没有时机」。 - **身份文档补一句机制**(`lib/persona.js` 的 `DIARY_BRIEF`):`写的时候用 growth_commit 交一条 type=diary——写多写少都行,不想写就不写。` 仍是第一人称、仍是常驻做法,不是任务派发;写不写由她决定。 - **当天最后一段给一个时机**(`lib/scheduler.js`):新增 `DAY_END_CUE = '(今天到这里了。这一刻是我自己的。)'`,只在当天最后一次叫醒里追加。判断抽成纯函数 `isLastWakeOfDay(config, counts)`——上限取 `daily.events` 与 `wakesPerDay` 里**更小的那个**(`plan()` 的两个闸门同时生效),已醒 `counts.sent` 次 + 这一次顶到上限即为最后一段;配额配成 0 或非数字时不加,免得乱给时机。这句只进 `fish:wake` 段(用户看不见),**不提日记、不派活**——提了就成了工单,正是 0.6.6 要去掉的东西。 - **回归用例**:`test/events-v2.test.mjs` 新增 2 条(两个闸门取小、超了仍算最后一段、坏配置不乱给);`test/herdiary.test.mjs` 新增 2 条(`DIARY_BRIEF` 必须含 `growth_commit` 与 `type=diary`、`selfdoc` 的 diary 分支仍在)。全量 `node --test` **821 tests / pass 821 / fail 0**(18.6s)。 ## 0.6.14 — 2026-10-29 **市场里的版本总比 GitHub 慢半天到一天,想升级只能自己去 Releases 手动下载(2026-10-29 用户 m00203)。** 发到 GitHub 之后,插件市场的索引要走一轮构建,短则半天、长则一天。这段时间里「检查更新」能看见新版本号,却点不动——原来的按钮只是一个 `target=_blank` 的链接,指去 Releases 页面让人自己找包。现在这条链子接到应用里走完:**检查到新版 → 问一句 → 下载 → 解开来验 → 再问一句 → 关掉应用、换包、自动重开**。每一步都不静默:用户没点确认,什么都不会发生。 - **新模块 `lib/selfupdate.js`**(零依赖,只用 `node:fs/path/zlib/child_process/url`)。下载源是 GitHub tag 的 tarball(直连 `codeload.github.com/ssamurias/dsh-fish/tar.gz/refs/tags/v`;两个镜像前缀 `ghproxy.net`、`ghfast.top` 不走 codeload——实测镜像对 codeload 地址一律回 403 `Invalid input.`,改走 `github.com/ssamurias/dsh-fish/archive/refs/tags/v.tar.gz`,与直连包逐字节相同)。三个地址都硬编码、不接受外部传进来的 URL;单源 25 秒、整批 60 秒预算,断了换下一个,全断才报错。 - **下载完先验包再落盘。** 不是「文件大小对了就算成功」:流式收完先 `zlib.gunzipSync` 解开(带 96 MB 上限),按 tar 头扫 `package/package.json` 与 `package/lib/index.js`,核对包名是 `dsh-fish`、版本正是要的那个、入口文件在。任何一条不符——镜像回了 HTML 错误页、缓存滞后给了旧版本、文件被截断——都当失败,`.part` 文件清掉,正式文件名一次都不会出现。这样「装上一个坏包」这条路在下载阶段就断了。 - **失败说人话、能重试。** 报错形如「这个版本没能下载成功。直连:等了 25 秒没下完;镜像 A:HTTP 500;…。过一会儿再试一次,或者开一下网络工具。」卡片里留着「重试下载」按钮,从 `failed` 直接回到 `downloading`;不要求刷新页面,也不假装成功。 - **两次确认,各管各的事。** 第一次在下载前:「要下载 v0.6.15 到本地并校验,这一步不会重启应用。继续?」——只下文件,不碰现有安装;第二次在安装前,先讲清代价:「安装会先关掉 DeepSeek Harness,再由后台脚本替换插件、装完自动重开(大概半分钟,期间窗口会消失)。现在继续?」两次都用 `window.confirm`,与插件里其他危险动作一致。 - **真正的替换交给一个独立脚本**(`<数据目录>/updates/install-v.cmd`,`detached` 起,宿主被杀不会把它带走)。顺序是死的:等 2 秒让 HTTP 响应先回到界面 → `taskkill` 宿主 → 轮询 `tasklist` 确认真的退出了(60 秒还没退就**什么都不改**直接重开)→ 备份 profile 的 `package.json`、`pnpm-lock.yaml` 与 `node_modules/dsh-fish` → 用应用自带的 node/pnpm 装**刚才下载并验过的那个包**:`add "file:<数据目录>/updates/dsh-fish-v.tgz" --config.minimumReleaseAge=0` → 读安装后的 `package.json.version` 核对 → 失败就还原备份并 `pnpm install` → `start` 重开应用。**装的是本地包,不是 `github:` spec**:`github:` 要 pnpm 跑 `git ls-remote` 打 `github.com:443`,本机实测 `Recv failure: Connection was reset`(HEAD 也 ETIMEDOUT)——codeload 和镜像都通、偏偏 git 协议不通,而国内用户大多如此。本地包零网络,实测 `+ dsh-fish 0.6.11`、1.4 秒装完;文件留在 `updates/` 不删,之后 `pnpm install` 还要用它。日志写 `updates/last.log`(脚本里全英文,cmd 默认代码页是 GBK;装完之后状态接口会把最后 12 行带回来给界面看)。`--config.minimumReleaseAge=0` 是必须的:24 小时的「太新的包不装」闸会正好拦住刚发的版本。 - **开发版不动。** 依赖里写的是 `link:`/`file:` 时(装到源码目录的那种),`kind` 判成 `local`,`managed:false`——界面直接写「这台机器上装的是开发版,不走自动升级」,服务端也拒掉下载与安装。否则一次升级就会把源码目录换成下载包。同理,`execPath` 是 `node.exe`(非桌面版)时拒绝安装:换上去了却没有能重开的 exe,等于把人关在门外。 - **路由**:`GET /dsh-fish/update/status`(阶段 `idle/downloading/prepared/installing/failed` + 进度字节 + 来源 + 可读错误;prepared/installing 时附脚本日志尾巴);`POST /update/download {version}`、`POST /update/install {version}` **只认 POST**(GET 一律 405——换成链接也不会「点一下就下」),缺 `version` 400,其它 `/update/*` 404。检查更新那条 `/update` 保持原样。 - **界面**:「关于」卡里检查更新旁边多一个「下载 v0.6.15」按钮,下载中显示百分比与已收字节,就绪后换成「现在重启并安装 v0.6.15」;原来的 GitHub 链接改文案为「或去 GitHub 手动下载」,还在。 - **回归用例** `test/selfupdate.test.mjs` 新增 26 条:地址与 spec 硬编码、`classifySpec` 六种、`profileDirOf` 的 link 识别、`runtimeBins` 找不到就退回 PATH、`inspectPackage` 的七种坏包(非 gzip/空/版本不符/包名不对/无入口/半截 tar)、下载成功不留 `.part`、断源换源、全败不落盘、内容不对不落盘、超时不拖住、脚本顺序(先等两秒再杀、真退出了才动手、装的是本地 tarball、含年龄闸、含版本核对、含回滚与 manifest 还原、含重开、**不含中文**)、无自带 pnpm 时退回 PATH、没有 tarball 才退回 `github:`、非 Windows 拒绝安装、状态机(prepared / 失败可重试 / 开发版拒绝 / node.exe 拒绝 / 起脚本 `detached`)、路由五种响应。全量 `node --test` **823 tests / pass 823 / fail 0**。 - **没做的事**(说清楚免得误会):下载完不自动装——第二次确认永远是人点的;不静默替换正在运行的包(避坑指南 §3.1 的 `ERR_PNPM_EPERM` 就是这么来的);不碰 `cordis.patch.yml`,也不改 `bundles`(插件条目本来就在,升的只是依赖版本)。 ## 0.6.13 — 2026-10-29 **请她走之后,那条对话还自称是大肥鱼(2026-10-29 用户 m00924 真机发现)。** 不是提示词污染,也不是没请走。请神段是每轮现组装的 system prompt 贡献,撤掉就没了——被请的会话前后两条 `system/message`(`seq=339` 含「此刻你在…」、`seq=352` 不含)证明它确实被撤干净了。留在里面的是**历史**:请神期间她以自己身份说过的话已经写进那条会话,append-only,删不掉;模型下一轮读到「自己」上一句「我是大肥鱼」,就顺着演下去。任何提示词清理都动不了已经写下的消息。 - **结束时补一条交接句**:`lib/possess.js` 新增 `releaseNoteText()` / `appendReleaseNote()`——`releasePossession()` 在撤挂载、结台账之后,往那条对话里追加一条**可见的 `user/message`**:`(大肥鱼回去了,这条对话还给你。)` + `请神期间她说过的话仍留在上面的历史里;想完全干净,开一条新对话最稳。`。宿主校验按 `dsh-session` 的真实形状来:`{ id, role:'user', source:{kind:'user'}, content:[{type:'text',text}] }`,并带 `{ surfaceOp:'append' }`(`user/message` 属 surface-eligible,不带宿主直接拒)。 - **不连坐**:交接句写不进去(对话已收起来、session 已关、宿主形状变了)只记一条日志,请神本身照样算结束,`/possession` 回包里带 `note:{ok,why}`,界面据此换提示语。 - **界面上说清楚**:「结束请神」上面多了一个「临走留一句(可不写)」输入框,最多 200 字,会被写进那条对话的交接句;卡片里明写「请神期间她说过的话会留在这条对话的历史里——结束时会补一条『我回去了』进去,想完全干净请新开一条对话」。不再让人以为「结束 = 全部恢复原样」。 - **路由**:`lib/observe.js` 的 `/possession` `action:'end'` 先从 `ctx.get('agents')` 找到那条活 agent(没带 sessionId 就用台账里的 active 那条),把 `agent` 与 `farewell` 一起交给 `releasePossession`,回包透出 `note`。 - **回归用例** `test/possess.test.mjs` 新增 5 条(`releaseNoteText` 的两态与截断、交接句是合法 `user/message` 且带 `surfaceOp:'append'`、写不进去不抛、`releasePossession` 先撤挂载再补交接句且互不连坐、路由与界面源码级断言)。全量 `node --test` **791 tests / pass 791 / fail 0**。 ## 0.6.12 — 2026-10-29 **请神的选择框里显示的是一串会话 id,不是对话框的名字(2026-10-29 用户 m00761)。** 设置 → 出门 →「请神(上身到一条正在用的对话)」的下拉原来是 `s.title || s.sessionId.slice(-8)`。问题在上游:服务端 `/possession` 从 `agent.session.header` 里找 `title`,而宿主**根本不往 header 里写标题**——它把名字写成会话事件流里的 `session/title` 事件,由 `sessionTitle` 服务折出来。于是 `title` 恒为空,客户端每次都退回 id 尾巴,用户看到的全是数字字母。 - **新模块 `lib/sessiontitle.js`**:`titleOfSession()` 按可靠性排三层——① 问宿主服务 `ctx.get('sessionTitle').get(session)`;② 服务不在(宿主版本不同、或测试里没有)就自己折 `session.snapshotEvents()`,从后往前取最后一个 `session/title` 的 `data.title`;③ 都没有才退回 header 的 `title ?? name`,再没有给空字符串。永不抛:拿不到名字不该让整页报错。另有 `titleOfAgent()`(从 agent 取会话)与 `clipTitle()`(截断,默认 80 字)。 - **服务端改名**:`/possession` 的候选列表改用 `titleOfAgent(ctx, a)`(`lib/observe.js`);请神台账里记的名字也同步走这条(`lib/possess.js` 的 `possessInto`),「请神:×××」的日志与界面上「现在上身:×××」才有真名字。 - **客户端三处**:下拉走 `possessName(s)`——有名字显示名字;两条对话重名时补上工作区目录名(`标题 · dsh-fish`)区分;还没起名字的显示「还没起名字的对话」,不再把 id 甩给人看。「现在上身」同样显示名字 + 工作区,不再回退到 `sessionId`。 - **回归用例** `test/sessiontitle.test.mjs`(6 条):服务优先、事件流兜底、无标题给空、header 只是旧版本退路、截断长度、以及源码级断言「下拉不再出现 `s.title || s.sessionId.slice(-8)`」。全量 `node --test` **786 tests / pass 786 / fail 0**。 ## 0.6.11 — 2026-10-29 **「出门」页签在公开版里没藏住,所有人装完都能看见(2026-10-29 用户 m00608 真机发现)。** 0.6.7 说好「出门页只在开发副本里露出」,判据是 `/outing` 回包里的 `dev: IS_DEV_INSTALL`,而 `IS_DEV_INSTALL` 的算法是「自己的 `import.meta.url` 路径里有没有 `node_modules` 这一段」。这个判据不可靠:发布包会被解包到插件目录、也可能经 junction / link 指过去,路径里根本没有 `node_modules`,于是 `dev` 恒真、页签在正式版照常渲染。换句话说,它猜的是「安装布局」,而布局不由插件决定。 - **判据换成文件开关**:`/outing` 的 `dev` 改用 `await devMode(root)`(`lib/devmode.js`)——`DSH_FISH_DEV=1` 或数据目录下存在 `.dev` 文件才算本机测试版。这跟「去刷 B 站」那个按钮共用同一个开关,与安装路径彻底解耦:没这个标记就是正式版,页签不渲染。 - **删掉 `isDevInstall()` / `IS_DEV_INSTALL`**:不再按路径猜安装形态,避免以后换一种装法又漏一次。代码、路由、审查卡照旧全部保留,本机 `.dev` 在,开发副本上页签与以前一样。 - **回归用例**(`test/outing.test.mjs`):断言 `lib/observe.js` 里没有 `IS_DEV_INSTALL`、没有 `includes('node_modules')`、`/outing` 的 `dev` 走 `devMode(root)`,且复用 `./devmode.js`——判据再被改回路径猜法时这条会红。 - 验证:本机 `node --test` **780 tests / pass 780 / fail 0**。 ## 0.6.10 — 2026-10-28 **GitHub Actions 四个 job 全红:三处「只在 Windows 上成立」的路径判断(2026-10-28 发布后自查)。** 本机 Windows 上 779 条全绿,推到 GitHub 后 `test` workflow 的 ubuntu(Node 20/22/24)与 Windows runner 全红。逐条对日志,全是平台依赖,不是逻辑回归: - **`normPath` 在 POSIX 上把 Windows 路径当相对路径。** `resolve('D:\\a\\home')` 在 Linux 上会拼到 cwd 后面(变成 `/home/runner/work/dsh-fish/dsh-fish/D:\a\home`),而 `isInside` 又用平台的 `sep` 拼前缀,于是「同一个家有大小写/尾斜杠两种写法」被判成「在外面」——`isInside / normPath`、`checkCaller`、`guardWrite` 三条一起红。现在 `normPath` 先认 Windows 写法的绝对路径(盘符 `^[a-zA-Z]:[\\/]` 或 UNC `^\\\\`):不管跑在哪个系统都按 Windows 规则归一(分隔符统一成 `/`、盘符路径小写),其余才走 `resolve()`;`isInside` 比较前把两侧分隔符都统一成 `/`,混着比也不会误判。**Windows 上的输出格式一字未变**——`~/.dsh-fish-homes.json` 里已记的家不会被当成新家、不会写假事件。 - **`normRoot` 剥不掉 Windows 写法的尾 `\`。** POSIX 的 `sep` 只有 `/`,`…\Home\` 与 `…\Home` 被当成两个家(`同一个家的不同写法不算换家` 红)。改成两种分隔符都剥。 - **自检用例把「缺 ffmpeg」连坐成「看画面坏掉」。** `tools.test.mjs` 早先断言自检摘要不含 `/看画面/`,只为证明「全局工具表查不到 `read_image` 时不能乱报红」;但 GitHub 的 Windows runner 没装 ffmpeg,另一项红项的标题正好叫「看画面用的 ffmpeg」,被这条断言误伤。改成直接断言红项里没有 `read-image` 这一项,不再拿文案做代理。 - 验证:本机 `node --test` **779 tests / pass 779 / fail 0**;另用 POSIX 语义模拟(`D:\deepseekagent\04-小工具\posix-sim.mjs`)逐条复算了 ubuntu 上那几个断言,确认修对了。 ## 0.6.9 — 2026-10-28 **反馈页的「常见问题解答」点了没反应;刷视频记录翻不了页(2026-10-28 用户 m00416)。** - **常见问题解答点了没反应。** 按钮原来是 `` 指向 `/dsh-fish/faq`,想在浏览器新窗口里看全文。但宿主的信任栅栏只认 App 内的登录 cookie,新标签页直接敲这个路径一律 403 —— 用户看到的就是「点了一下,什么都没发生」。现在改成 `button` + **就地展开的面板**:点一下向 `/faq.data`(同源、带 cookie)取同一份 JSON,在卡片里摊开一个固定高度、内部滚动的面板,看完点「收起」;不把下面的「找开发者 / 进群」挤出屏幕,也不依赖任何窗口策略。整页 HTML(`/dsh-fish/faq`)保留,浏览器带登录态仍可看,断网也行。 - **刷视频记录按条翻页,每页条数随实际量自适应。** 0.6.6 之前是「按天分组、每页三天」——一天刷 10 条、查 3 天就是 30 条挤在一屏,只能一直往下滚。现在摊平成一条一条,日期只在换天处出现一次;**每页多少条不是死数字**,而是看总量与日均密度:一共 ≤ 8 条就一页全放下(连翻页条都不出,避免「第 1 / 1 页」这种噪音);日均 > 20 条 → 6 条/页,> 10 → 8 条,> 5 → 10 条,其余 12 条。刷得越密每页越短,重度用户一屏不会顶到十几条,轻度用户也不用为翻页而翻页;页码旁会写「一页 N 条」。查询范围(1 天 ~ 1 年)不变。 - 测试:`faq.test.mjs` 新增「/faq.data 吐 JSON」与「按钮不再是 target=_blank」两条(5→6);`client.test.mjs` 新增「按条翻页」一条(20→21);`ui.test.mjs` 那条 10-24 写的「一天里的条目全部列出」(断言源码里还留着 `g.items.map(`)按新语义改写——现在断言的是「全部摊平后再分页、没有 `slice(0, 8)`、两个翻页按钮和页码文案都在」。全量 779 条全绿。 ## 0.6.8 — 并入 0.6.9 **分身与重复的会话(2026-10-28 房客日志复盘)。** 一位用户把插件装到 `D:\DSH\plugins-src\dsh-fish`,又在 `cordis.patch.yml` 里换了两次 `root`,最后侧栏里出现好几间「大肥鱼的家」,重启还会继续长新的。日志逐条对完,是三件不同的事: - **归档过的会话被当成「没了」,于是每次启动重建一间。** 判活只看工作区的会话清单、又把已归档的排掉——用户手动归档之后,下一轮启动就判死重开。现在 `home.json` 记 `status: 'archived'`(含 `archivedAt`),归档态下 `ensureTalkSession` 直接返回「这个家的会话被收起来了」,不再自动新建;想重开有明确入口(见下)。自己的 `/reset`(重置一局)仍旧新建,那是它本来就该干的事。 - **同一间房被并发建两条。** 启动链路和「她要说话」链路都会调 `ensureTalkSession`,没有单飞锁时各建一条。现在按规范化后的 root 共享同一个 promise;另外 `home.json` 的 `createdAt` 在 10 分钟内时,即使宿主索引还没列出那条会话也按「可复用」算,避免索引延迟造成重复。 - **换了 root 没有任何痕迹。** 新家从零开始,旧家的东西原封不动留在旧路径——这在用户眼里就是「一夜之间多了个分身」。现在在数据目录之外记一份 `~/.dsh-fish-homes.json`(可用 `DSH_FISH_HOMES` 覆盖),root 变了就在事件流里写一条 `meta`:「家换了:A → B。这不是搬家——旧家的东西还在旧地方,没有并过来;这边从一开始就是新的」,同时进日志、进 `/state.homes`,设置页数据页顶部也会提示。 新增 `lib/homes.js`(`homesFile` / `normRoot` / `readHomesSync` / `noteRoot`);`home.js` 新增 `HOME_GRACE_MS`、单飞锁与 `force` 参数;`observe.js` 新增 `POST /home/rebuild`(要 `confirm: 'rebuild'`)与 `/state` 的 `homes`、`home` 字段;设置页新增「会话被收起来了」卡片与「重新搭一間」按钮,换家时给一行路径说明。测试:`home.test.mjs` 两条旧断言反转、新增宽限与并发两条;新增 `homes.test.mjs` 5 条。全量回归 777 条全绿。 **注意语义变化**:以前「把会话归档」等于「下次启动重开一间」;现在归档就是收起来,重开要点「重新搭一間」。 ## 0.6.7 — 并入 0.6.9 **两份大肥鱼共用一个家(2026-10-28 房客反馈)。** 那位用户把插件文件夹手工搬到别处,结果两个工作区都加载了大肥鱼、都落到同一个数据目录,最后出现「两只鱼、一份记忆」。插件此前对此毫无察觉:无论从哪条会话进来,`growth_commit` 都往同一个 `events.jsonl` 追加,事件里也不带「谁写的」。 现在分三层拦住: - **认家。** 加载时在数据目录写 `instance.json`(instanceId、心跳时间、进程号、机器名),每 15 秒续一次;45 秒没续或同机进程已死即视为前任崩了,新实例立刻接管(重启不会被自己锁住)。 - **后进来的不写。** 占用被别的活实例握在手里时,`growth_commit` / `her_leave` / `bili_speak` 直接回一句「这个家被另一个实例占着」并说明怎么分开,身份文件也不落盘;已在里面的旧事件一律不动。 - **认谁在写。** 工具调用前核对调用者会话的 cwd 属不属于当前家,不属于就拒绝并说明「这不是这里」;每条事件新增 `sessionId`(最多 120 字符,去控制字符),事后能查到是哪条会话写的。 新增 `lib/tenant.js`(`callerOf` / `checkCaller` / `claimRoot` / `beatRoot` / `releaseRoot` / `guardWrite`);`schema.js` 认 `sessionId`;`capture.js`、`letter.js`、`speak.js`、`selfdoc.js` 接入;`index.js` 启动时认家并起心跳,`/state` 回包新增 `tenancy`;`observe.js` 透传。新增 `tenant.test.mjs` 11 条(双实例拒绝、死 pid 立即接管、TTL/release、心跳失败不改归属、身份文件两路守卫、schema 的 sessionId)。全量回归 768 条全绿。 **给搬家的用户**:正确的分家方式是在 profile 的 `cordis.patch.yml` 里给这个插件条目配 `root`,两个工作区各指一个目录,不要复制插件文件夹。升级插件包不会重写 `cordis.patch.yml`(插件只写自己数据目录下的文件),但升级后要确认那条 loader 条目的 `id` 仍然对得上——对不上时 patch 会静默失配,两个工作区就又会回落默认的同一个目录。 **「出门」页只在开发副本里露出来**(2026-10-28 用户要求)。这个功能还要真机跑一段时间,发布版先不放到设置页上:服务端按自己的 `import.meta.url` 判断是不是装在 `node_modules` 底下,`/outing` 回包带上 `dev`;设置页只在 `dev === true` 时渲染这个页签。代码、路由、审查卡全部保留,本机 `link:` 安装的开发副本照常显示——不是删功能,只是收入口。测试同步改为断言 10 个页签、`devMode` 过滤与 `/outing` 的 `dev` 字段。 ## 0.6.6 — 并入 0.6.9 **四条用户反馈**(2026-10-28 用户转来): - **「让我看日记」会自己关回去。** 根因是设置页的「整表自动保存」:任何一项环境参数改动都会在 700ms 后把**整份**配置 POST 一遍,而 `buildConfig()` 里带着日记三项;`/state` 回包比手速慢一点,刚落下的开关就按旧值被拨回界面、再被整表写回文件。现在日记三项**摘出整表**,各走各的窄 patch(`{diary:{visible}}` / `{diary:{enabled}}` / `{diary:{keepDays}}`),patch 未回包期间不让 `/state` 覆盖输入框;`/state` 里 `diary` 字段不是 boolean 时一律跳过,不再把 `undefined` 当 `false`。 - **领养人两格被登录账号顶掉。** 0.5.10 起服务端已不再回填(#2),仍复现的路径有三条:①用户点的是「测试」——它只验 cookie、不保存两格,旧文案却让人以为会保存;②`owner.json` 被 ≤0.5.9 写脏且没有迁移;③cookie 认不出来时旧代码**整单放弃**,连 `mid`/昵称一起丢。现在:测试按钮改叫「测试 cookie 能不能说话」并写明「不保存、不动上面两格」;保存时 cookie 解析失败**仍把两格存下**;两格与登录账号相同时界面点出「像是旧版本写进去的」。 - **看视频记录只看得见 4 条。** `recordBrowse` 原来把每次刷站的结果塞进**一条** meta,超过 1800 字就从尾部 `slim.pop()` 丢条目(带封面的单项 ≈350 字 → 每次只剩 4 条),而 `looked:` 计数不受截断影响 —— 于是「累计看过 24」配「抽屉里 4 条」。现在按 1800 字**分片**写成多条 `browse:` / `browse-more:` 行,读取侧(历史列表、外出、封面兜底)全部分片解析;`browseCountToday` 的当日配额口径不变。 - **风控提示不清。** 读/写两条冷却提示都带剩余时间(「B站风控冷却中,还要约 N 分钟才能写」);`browsePlan` 的 note 带分钟数;观测台把「落盘的冷却」与「进程内 60 秒熔断」分两句话讲,并说明后者重启就清空(`/risk` 回包新增 `memory` 字段)。 新增测试:`client.test.mjs` 3 条(日记不进整表、风控两层提示、领养人脏值提示)、`owner.test.mjs` 1 条(cookie 认不出仍存两格)、`bili-risk.test.mjs` 1 条(`memoryCooldown` 形状)、`p6.test.mjs` 1 条(note 带分钟)。全量回归见下。 **递过来的视频,她以前永远评不了**(2026-10-28 用户反馈)。截图里她自己的说法是:「平台今天推给我的视频 能;我自己搜出来的 不能;你发链接给我的 也不能」。根因两处:评论闸门 `targetsToday()` 只认刷站记账 `browse:`;`bili_look` 又只有 `home/search/related`(`related` 是「接着这条往下看别人的推荐」,看不到指定那一条)。口径本来就错了——该管的是**她有没有真的看过**,不是**谁让她看的**。 现在可评论的目标是三种来源的并集:刷站记账、主动看过的 `look:<日期> `(`bili_look` 每次「看」都记一行)、看过画面的 `watched:<日期> `。**递过来但没看一眼的不算**:`turns.js` 从侧栏真人消息里认出 BV 号只记 `hand:<日期> `(事实,不等于看过),拒绝理由会写明「这条是他递过来的,你还没看」。昨天看过的今天也不能拿去评(`looked:` 无日期,`look:` 带日期,两者并存)。 `bili_look` 新增 `what=video`:按 BV 号看指定那一条。详情只取一次——`absorb()` 现在可以接收调用方已取回的 `view`;b23.tv / bili2233.cn 短链由新增的 `client.resolveUrl()` 解析成 BV(跟随跳转取落地地址,跟不到再从正文里抠;只认 BV,不认 av 号)。设置页「在 B 站说话」的说明句同步改成三种来源。 新增测试:`look-video.test.mjs` 7 条(按 BV 看指定一条并落两笔账、黑名单不看也不记账、认不出就直说、短链交给 client、absorb 复用详情、resolveUrl 带 BV 不联网、从正文抠 BV 与「不是视频」)、`p4.test.mjs` 2 条(`targetsToday` 并集与日期口径、hand 之后 look 才能评)。 **设置页多了一个「常见问题解答」入口**(2026-10-28 用户要求)。反馈页第一张卡就是它,点了在**新窗口**打开 `/dsh-fish/faq`:整页 HTML、自带深浅两套配色、不引任何外部资源(断网也能看)。正文是作者那份《常见问题解答.doc》的逐字搬运,12 个问题(权限、接别的渠道、上下文撑爆、遗忘机制、对话认知条数、字数限制、换对话、找不到成长事件、看不了视频、加功能……),不是摘要。没在设置页里铺开——太长了,展开会把整个页面顶歪。 新增文件 `lib/faq.js`(正文 + `renderFaqPage()` + `escapeHtml()`;正文一律转义,这一页没有一处该被当成标签);`lib/observe.js` 注册 `/faq` 路由(`content-length` 按 `Buffer.byteLength` 算,中文标题才不会被截断);`lib/client.js` 反馈页加按钮。用的是原生 `target="_blank"` 链接而不是 `window.open`,不受弹窗拦截,也能中键开。 新增测试:`faq.test.mjs` 5 条(12 条正文与原文档对齐、页面自包含无脚本无外链、转义、`/faq` 路由 200 + content-type + 字节长度、反馈页按钮确实指向 `/faq` 且新窗口)。 ## 0.6.5 — 并入 0.6.9 **请神:把一条正在用的对话临时交给她。** 从下一轮起,回你话的是她——身份是真的、工具是真的,不是演。结束时撤销,那条对话恢复原样。 换人靠的是宿主自己的分段名,不是往提示词里再塞一段。宿主 `@deepseek-ai/dsh-system-prompt` 的段是 `deployment:persona-prefix`(order 10100)与 `deployment:persona-suffix`(10200),而 **scoped ctx 上注册同名段会覆盖全局那一段**(宿主注释原话:a scoped section shadows a global section with the same name)。这两个名字是从安装包 `app.asar` 里把 `dsh-system-prompt` 抽出来读到的,不是猜的——0.6.3 猜过一轮,猜错的名字不会报错,只会静默不生效。现在覆盖失败会退回插件自己的段名 `fish:possession`(order 10150)并写一行日志说明,不假装成功。 三条边界: - **只能请到活着的对话。** 宿主 `agents.get(sessionId)` 只认当前在内存里的 agent;没打开过的对话请不到(打开一次之后才会出现在下拉里)。 - **她自己的家请不了**;**她正在出门时也请不了**(先叫她回家)。出门与请神是两条路,同时开会互相打架。 - **守卫会连你一起拦。** 请神期间那条对话的参数级拦截(她的屋子、`~/.dsh/{memory,sessions,profiles}`)照样挂着,而它分不出这条消息是谁打的——你自己在那条对话里读这些路径也会被拒。界面把这条写在按钮旁边,没有藏。 撤销是可验证的,不是「关了就当没了」:两层段(前缀覆盖 + 后缀清空)在 `agent.ctx` 的 effect 上注册,结束请神时按逆序 dispose;台账 `~/.dsh-fish/possessions.json` 记 `{sessionId,title,startedAt,status,identity}`,宿主**冷会话**被打开时(`agent/created`)id 对得上就补挂,不用重新请一次。身份用**请神那一刻的快照**(与出门同一套做法),不是每轮重读家里的活文件。 新增 `test/possess.test.mjs` 8 条(正文、同名覆盖、覆盖失败退路、宿主没有 `systemPrompt.section`、冷会话补挂只认 id 对上、坏台账当没有、台账存快照、出门占着时拒绝)。全量回归 **739 passed / 0 failed**。 ## 0.6.4 — 并入 0.6.9 **按仓库里三个 issue 修的三处**(#5 风控熔断、#6 侧栏轮数、#7 日记覆盖与不可读)。 **#5 一次 -412 被放大成 30 分钟不可用,间隔也改不了。** 原来 `lib/bili.js` 只有一个全局 `riskUntil`:任何一次 `-412` 都把这个进程按死 30 分钟,读和写不分——刷一次首页碰上风控,连看视频、发评论一起停摆。现在拆成两档:**读**(搜索/首页/评论列表/弹幕)从 60 秒起、连续复发翻倍、封顶 10 分钟;**写**(发评论、回复)从 5 分钟起、封顶 30 分钟。任一次成功就立刻解除自己那一档。另外**签名接口**(WBI 签名的 `search`)收到 `-412` 时先强制刷新一次密钥重试一次,还不行才认作风控——那类 `-412` 里有一半其实是 `w_rid` 过期。`-352`(风控校验失败)、`-509`(请求超限)一并按风控处理,其余错误码不再误伤。 请求间隔终于**可配**了:`bili.minIntervalMs`(默认 800,夹在 200–120000)与 `bili.jitterMs`(默认 500,夹在 0–60000),设置页「B 站」页签可改;`null` / 空串 / 非数字一律回落到默认值,不会把间隔配成 0 去撞风控。改完下一次请求就生效(`loadConfig` 里直接喂给客户端)。 **#6 在侧栏亲口跟她说话,`【turns】` 不涨,`chat.rotateAfter` 永不触发。** 根因是 `bumpTurns()` 只有 `/say` 路由一个调用点——那是观测台/IM 替人递话的路,用户在侧栏直接打的字根本不经过它。新增 `lib/turns.js`:监听宿主 `session/event`,认准"她住的那个会话",收到**真人打的** user 消息就记下,等这一轮 `turn/end` 再结账(turns +1、`talkmem` 记原话、满 `rotateAfter` 就换会话,和 `/say` 走同一套 `rotateTalkSession` + 身份文档刷新)。换会话时还会往事件流追加一条 `meta`(`rotate::<新会话 id>`),事后查得到。 来源必须先分清,否则轮数一定失真:真机会话事件里 `runtime-context` / `skill-catalog` / `plugin:xxx` 这类注入的 `source.kind` 不是 `user`;插件自己 `prompt()` 进去的消息 `kind` 也是 `user`,但 **`rpcId` 带 `wake-`/`say-` 前缀且没有 `clientTimeZone`**;只有客户端亲手打的字才带 `clientTimeZone`。认来源的判据就是这条,宁可少算一轮,也不把每晚的唤醒算成人说话(那会让 `rotateAfter` 天天提前触发、她天天失忆)。 **#7 同一天写第二次日记会静默覆盖,而且她自己读不回。** 两半都修了。 - **覆盖**:`writeDaily()` 在覆盖 `diary/YYYY-MM-DD.md` 之前,如果旧稿存在且内容不同,先存到 `diary/archive/YYYY-MM-DD-HHmmss.md` 再写新的。界面上仍然是"一天一篇"(客户端按日期分页不用改),`listDiary()` 的正则只认顶层 `YYYY-MM-DD.md`,归档版不会被当成第三篇日记。内容相同不归档,归档失败也不阻断本次写入。 - **读不回**:新增只读工具 `her_diary`(`lib/herdiary.js`),加进 `HER_TOOLS` 与身份里的日记说明。它**只读、只能读日记目录、参数是日期不是路径**(`YYYY-MM-DD` 之外一律不认,`../` 传不进来),一次一份、正文截到 3000 字,也可以只列写过哪些天。**不参与身份文档拼装,也不进 `her_recall`**——`diary.visible` 管的是"主人看不看",这个工具管的是"她自己愿不愿意回头翻",两个开关不交叉,原来那条"日记不进事件流"的设计没被拆。 新增 `test/turns.test.mjs`(来源判据、真机事件形态、结账、换会话、非她屋子不记、插件消息不记、注销后不再记)、`test/herdiary.test.mjs`(最近一篇、指定日期、日期清单、非法日期、那天没写、截断),`test/dailylog.test.mjs` 补了同日覆盖归档的用例。 **顺便修了出门那条兜底守卫「看不见实参」的毛病。** 0.6.3 的 `attachAwayGuard` 只读 `exec.args` / `input` / `params`,而宿主 `@deepseek-ai/dsh-tools` 的 `createExecution` 只挂 `exec.arguments`——守卫每次拿到的都是空对象:日志上写着「外面这一条挂上了兜底守卫」,实际一次也没拦。2026-10-28 20:55 那趟真机就是在这条缝里用 `ops_bash` 把 `PLAN.md` 读走的。现在四种形状都认,`test/away.test.mjs` 的用例改成宿主真实形状(`{name, arguments:{…}}`)并保留 `args` 变体。全量回归 **731 passed / 0 failed**。 ## 0.6.3 — 并入 0.6.9 **她可以出门了:去别的工作区干一趟活,回来再判这趟算不算数**(用户 m07453 定的方案,文档见 `docs/E-出门与请神-方案.md`)。 以前她只有一间屋子:`~/.dsh-fish` 那个工作区,工具面被 6+1 白名单收窄到只剩自己那几个。想在别的项目里干点什么,只能由人手动把那个项目的工作区开给她,开完还得一路盯着。现在设置页最后多了一个「出门」页签:选一个工作区,在那边开一条新会话,她继承那个工作区**本来的权限和工具**(不套家里那套收窄过的名单),第一条消息由插件递过去,说明她在哪、能用什么、别动自己的屋子。 **出门不是「换个地方聊天」,是换一套规矩。** 所以有四条边界: 1. **同一时间只能出一次门。** 第二条会被拒——同时出现在两个工作区,回家时两份记录会互相踩。 2. **出门期间家里那套日程整段暂停**(`lib/scheduler.js` 的 `tick()` 开头先看台账):不叫她、不刷 B 站、不写日记。原因同上:两边同时醒着,`events.jsonl` 就是两个人抢一支笔。 3. **回家时先验事件流。** 出门前记下 `events.jsonl` 的字节数 + 尾部 4096 字节的 sha256(`lib/outing.js` 的 `snapshotEvents`);回家时三种结论:**变小**(被删过行)、**大小没变但尾部变了**(被原地改写)、**只是追加**(她自己写日记,正常)。前两种默认**不收**:`status` 停在 `pending`,她留在外面,等人看过再决定——这条是用户拍的板(「不能只收下说得漂亮的」)。 4. **「这趟不算数」不是删记录。** 驳回只把这一趟标成 `rejected`,事件原文仍在 `events.jsonl` 里(第 5 条铁律:只能追加),只是 `deriveSelf` 跳过这一段。话术也不是「你没去过」,而是「那部分我不认,它不算你」。 **在门外,她读到的是「走的时候的自己」,并且伸不回家里。** 出门那一刻,身份文本会被归档成一份只读快照写进台账(`lib/outing.js` 的 `identity` 字段),外面那条会话的 system 段读快照,不读家里那份随时可改的文件——否则人已经出去了,身份还能被自己在别处改写。同时那条 agent 上挂了兜底守卫(`lib/identity.js` 的 `attachAwayGuard`):任何工具调用里出现她家目录的路径就拒绝,话术与家里的收窄一致(「这件事我做不到。」,不带权限/禁止字样)。这层不是防外人,是防「自己伸手回家」——收窄挡的是「看不见」,挡不住写文件。 **回家卡上先看得到比对结论,再决定收不收。** `/dsh-fish/outing` 的 GET 现在顺带算一遍事件流比对(只读,不写盘),出门页签「现在在哪」那张卡直接写「事件流只多出 N 字节」或者「不对劲:…」;`identityFrozen` 也一并返回,卡上会说明她带的是什么时候的自己。 **身份判据拆成了两条**(`lib/restrict.js`):`isHerHome` 只认她自己的屋子——**工具收窄**用它,出门了就不该按家里的名单收;`isHerAnywhere` 认家 + 出门名单上的那个 cwd——**身份挂载**用它,出门那一位仍然是她,否则她在外面会失去全部来历。原来的 `isHerAgent` 保持行为不变,作为 `isHerHome` 的别名留着。 **请神这一版没开。** 它要在别人 agent 的 scoped ctx 上注册**同名** system 段来盖掉人格、用影子工具盖掉工具面;而宿主那些段名(`systemPrompt.section({ name })` 的那一串)没有在运行期 dump 过,写错不会报错、只会静默失效。`/dsh-fish/possession` 现在 GET 如实说「还没启用」、POST 一律 409,设置页那张卡也把理由写在脸上——**宁可没有,也不要一个看起来装了、其实没盖上的请神**。 **这一趟做了什么,回家时逐条摊开;不收下的那条也不删。** `/dsh-fish/outing` 的 GET 现在把 `away.since` 一起返回:`lib/outing.js` 的 `eventsSince` 按 `ts >= startedAt` 筛出这一趟新增的事件(排除 `outing-reject:` 这类插件自己写的标记行,最多给最后 300 条,`total` 仍是真实条数)。出门页签点「叫她回家…」先展开这张卡:每条一个勾选框、默认全勾,文本截到 400 字;`type === 'refusal'` 或 `verdict === 'refuted'` 的条目前面单独标「顶回来的」——只收下说得漂亮的,等于把这趟教坏。 取消勾选后点「这趟收下」,`POST /outing` 带 `rejectedIds`;服务端确认事件流没被动过之后追加一条 `meta`(`text: 'outing-reject: …'`,走 `validateEvent(…, { source: 'system' })`——只有 system 才能写 meta)。`deriveSelf` 读到这条就把那些 id 跳过:**原文仍在 `events.jsonl` 里,观测台和导出照样看得见**,只是不再参与她对自己的判断。第 5 条铁律是「只能追加」,所以「不收下」只能是读时跳过,不能删行。「整趟不算」走另一条路(`kept: false`),台账里 `status: 'rejected'`,不写逐条标记。 **真机第一趟抓到一个顺序 bug:会话先建了,台账还没写。**(2026-10-28 13:18 / 13:32 两趟真出门) 两趟的台账都对:第一趟 6 秒、第二趟 88 秒,身份快照 2834 字符、基线 98400 字节都在。但日志里既没有「外面这一条挂上了兜底守卫」,也没有「身份段已挂进 system prompt」——**她出去的时候没有身份段,也没有兜底守卫**。根因是 `POST /outing` 先 `sc.create()` 再 `startOuting()` 写盘:宿主在建会话时**同步**触发 `agent/created`,`lib/identity.js` 那条钩子读到的是「她没出门」,于是整段跳过。同一根因还有另一半:`readOutings` 按 `sessionId` 过滤,空 id 的那条会被当坏档丢掉。 改法是「先记账,再建会话」:`startOuting` 允许 `sessionId` 先空着;`sc.create()` 之后用 `bindOutingSession` 把 id 补进台账(幂等),建会话失败则 `abortOuting` 撤掉空壳(否则她会一直「挂在门外」,家里调度也跟着停)。`agent/created` 那边三种都认:台账记的 id 对上、cwd 就是出门那个工作区、或者台账刚写还没 id 而这条会话还没 cwd;认不出来时留一行「有条出门记录,但这条会话没对上:cwd=… sid=…」,不静默跳过。 新增 `lib/outing.js`(台账 + 基线 + 判据 + `eventsSince`,独立于 `events.jsonl` 结构)、`test/outing.test.mjs` 14 条(互斥、三种比对结论、坏档容错、`awayLine` 文案、空 id 先记账 + `bindOutingSession` 幂等 + `abortOuting` 撤空壳)、`test/away.test.mjs` 10 条(身份快照、兜底守卫拦不拦、没 `tools.guard` 时如实报错、出门那条挂两段而家里那条不挂守卫、**会话还没有 cwd 时也认得出出门并把 id 补进台账**、认不出来要留日志)、`test/review.test.mjs` 5 条(只摊开出门后新增、没给时刻不猜、limit 截断但 total 真实、`deriveSelf` 跳过被取消勾选的条目、一条标记带多个 id)。全量 `node --test` = **704 passed / 0 failed**(0.6.2 基线 675 + 新增 29)。 **真机第三趟暴露了更难查的一层:她不该读到的东西,是「记忆注入」送进去的,不是她翻出来的。**(2026-10-28 14:13 那趟,`session-5fd70849`) 那趟她在 `deepseekagent` 里待了 17 分钟:读文件、搜网存图、回家。日志里身份段和兜底守卫都挂上了,`~/.dsh-fish` 也确实没被读——但她思维链里出现了「四条黑名单代码级硬禁(不撒娇卖萌、无好感度数字、不读自身数值与设定、不能调工具改自己)」的原文,还拿它当理由拒绝了一次读自己屋子。查会话原文才看清:她在最开头收到的**第一条 user 消息**不是人打的字,是 `@a9i5k4/dsh-auto-memory` 注入的记忆块——那个工作区的白板(禁令原文就在白板里)和交接账本(「先记账、再建会话」、`bindOutingSession`、基线 98400 字节、身份快照 2834 字符,全在)。同一块每轮刷新一次,整趟注入了三次。**文件路径拦截拦不住这条路**:注入是宿主按工作区拼好递进去的,不经过任何工具调用。 所以「出门」现在有两种: - **原来的出门**(去某个已有工作区):能干活,但那个工作区有多少记忆,她就带过去多少。选到插件源码目录时界面会亮警告——同一个道理,源码里有禁令清单。 - **出门口袋(干活模式,2026-10-28 用户 m01145 拍板)**:一个从没写过记忆的空目录(默认 `~/outing-pocket`,可用 `config.outingPocket` 改),第一次开门时自动登记成工作区。那边没有这里的白板,也没有这十几天的账,每轮注入只剩骨架;她的身份、工具、网络照旧。设置页「出门」页签多了一张「出门口袋」卡,按钮是「送她去口袋」。 **读拦截也跟着扩到了宿主记忆库。** 兜底守卫原来只挡 `~/.dsh-fish` 和插件的根目录;现在 `lib/identity.js` 的 `awayBlockedPaths` 还会加上存在的 `<用户目录>/.dsh/memory`、`.dsh/sessions`、`.dsh/profiles`——白板、交接账本、每日日志、所有会话原文、profile 配置都在那下面。**记忆类工具(`memory_recall` 等)不在这一层挡**:它们的参数是关键词不是路径,用户拍板保留(干净工作区里已经没东西可召回)。 **代价写进界面,不藏着**:沙箱的写权限跟着工作区走,她在口袋里写不进 `02-我的项目` / `03-我的文档` / `04-小工具`。真机那趟已经踩过更硬的一版——`workspace-write` 下 bash/node 对**含中文祖先的路径**一律 `EPERM`(`mkdir`/`touch`/`cp`/`cat >` 全挂,连指向它们的 junction 也挂),11 分 46 秒里 8 次 `Permission denied`、2 次等人点批准,真正有效动作只有三步。要交付就从口袋搬,或者回到主工作区再动。 新增 `lib/identity.js` 的 `awayBlockedPaths` + `lib/observe.js` 的 `ensurePocket` / `pocketDirPath` + `/outing` 的 `pocket` 字段与 `{ action: 'start', pocket: true }` + 出门页「出门口袋」卡(`lib/client.js`);`test/away.test.mjs` 加 1 条(宿主记忆库路径被挡、不存在的目录不凭空多一条)。全量 `node --test` = **705 passed / 0 failed**。 ## 0.6.2 — 并入 0.6.9 **自检里「看画面」单列一项,缺 `read_image` 不再是绿行末尾的一句附注**(2026-10-28 用户转来别人的运行日志:「她的大肥鱼看不到视频画面,但是 ffmpeg 也安装了,是因为看视频设置到上限了吗」)。 那台机器 ffmpeg 装好了、额度也没用完,`bili_watch` 走完整个流程都没报错——问题在更上游:宿主没注册 `read_image`,她能抽到帧,打不开那张图,于是整个「看画面」等于白做。而当时自检把这件事写在「手上的工具 ✓」那行的末尾(「宿主没给 read_image,她看不了图——不影响说话」),前面一个绿勾,没人会去读。 现在它是一项独立的 `read-image`:「看画面」。没给工具表的假环境(预检、部分测试)里这一项干脆不出现,不冒充结论。 **然后当天深夜就被自己的判据打脸,改成「有旁证才算有」**(用户 m05885:「那为什么我这个可以看图呢,还是说她假装在看图,我根本没发现她看不了图?关键是她之前还看图成功了,还能把抽帧的视频画面讲明白」)。 `!tools.get('read_image')` 这个判据是**必然误报**:宿主把 `read_image` 注册在 agent 平面(由 `attachments` 服务挂着),全局工具表里根本查不到它——所以任何真机都会报这一项红。反证在用户自己的会话里:`bili_watch` 抽出的 1096×622 PNG,`read_image` 读回来的是一整个 image 块,她照着画面把内容讲了出来(「画面是黑的底,白符号是实心的。橙色小人被夹在两个 −1 中间……」)。 判据改成四条,按证据强弱排:① 全局表里直接看到 → 绿;② 收窄真的挂上了(`applied > 0`)且名单里有它 → 绿(`restrict` 遇到不存在的名字会抛错,等于宿主亲口认过);③ 宿主明说了没挂 `attachments` → 红,给三步修法;④ 都查不到 → 绿,写「查不准,让她看一次画面就知道」,**不再替她宣布看不见**。`/dsh-fish/selfcheck` 顺带把 `attachments` 服务的真实状态传进来(有 `ctx.get` 就是权威答案,没有就别猜)。 顺带把自检的修法从 `.tag` 胶囊改成单独一行:原来是一句话还行,这次的三步会被撑成挤在一起的长条。 **skip 日志不再每分钟刷一条**(同一份日志里 22:15→23:00 每分钟一行 `skip 今日主动唤醒已满(1/1)`)。 跟 0.6.2 上面那条 `quiet:` 刷屏同源:`skip` 也不带 `wake:` 前缀,`todayCounts` 拦不住它,原因又是当天固定的配额,于是同样的字被重写上几十遍,真正的报错(工具有没有注册上)反而被埋掉。现在同一小时 + 同一原因只记一条,换小时或换原因才再写;判定走纯内存(`lib/scheduler.js` 的 `shouldLogSkip`),不往 `events.jsonl` 里写噪音。 新增 `test/boot.test.mjs` 一条用例盯这个函数(同小时同原因静默、换小时再记、换原因再记、跨日再记),`test/tools.test.mjs` 里原来「read_image 缺失只提示、不算失败」的断言改写成「全局表查不到但收窄认过 → 绿;没挂 attachments → 红;什么旁证都没有 → 判不准、不报红」。 **整个家可以搬到别的盘**(用户 m06510:「有些人不想让插件的工作区在 C 盘,他们可能想要挪到 D 盘、E 盘」)。 以前状态库写死在 `~/.dsh-fish`。现在设置页「数据」页签的「数据在哪」那一格下面多了一张「迁移工作区」卡:点「选择文件夹…」调宿主的目录选择器(web 模式下退化成手填路径),选定后点「搬到这个文件夹」就开始搬。 搬的动作是**复制 + 逐字节核对**,不是剪切:先按相对路径整棵复制(跳过 `logs/`、`backups/`、`dsh-fish-backups/`、`node_modules/`),再把每个文件的字节数跟源目录比一遍——有一个对不上就整体判失败,**且不写指针**(等于没迁过,她还住在原地)。核对通过才写指针 `~/.dsh-fish-location.json`(刻意放在数据目录**之外**,否则它跟着数据一起被搬走就等于没存)。 旧目录不删:搬完把源目录改名成同级的 `dsh-fish-backups/moved-<时间戳>/`,跟重置、恢复一个规矩。旧的工作区注册会一并清掉(`workspaceController.delete`),旧会话归档,不会在侧栏留下一个指向空路径的「大肥鱼的家」。 **重启 DSH 才生效**——状态库路径在插件启动时算一次并分发给十几个模块,中途换路径会让一半模块继续写老地方。卡片上直接写了这一句。 **拒绝的四种情况**:目标是空的、或者不是文件夹;目标里已经有一份非空的 `dsh-fish`;目标在你现在这个家里(会把自己装进自己);目标会把你现在这个家套进去。平级搬(比如 `C:\Users\你\.dsh-fish` → `D:\大肥鱼`)是允许的,那是最常见的一次搬迁。 `DSH_FISH_HOME` 环境变量仍然优先于指针文件:想临时指到别处(跑测试、开第二份档)不用动配置。 **导出包补上四样东西,下次「看不见画面」不用再靠猜**(用户 2026-10-28:「补三件事,让下次可判」——实际是四件,这里一并做完)。 另一台机器上反馈「她看不了视频画面」,导出包发过来却判不出来:里面只有配置里写的 7 个白名单名字、没有真正生效的工具列表、没有模型信息、也没有 `watched:` 记录,只能靠猜是被哪一条挡下的。现在「设置 → 大肥鱼 → 数据 → 导出运行记录」那份 txt 里多了四样: 1. **实际生效的 allow 列表**(`readRestrictState().effective`,`lib/restrict.js`)。`config.tools.allow` 只是「想放行」:收窄还没认过、宿主拒绝、这一局还没收到注册结果时,名单里写的名字一个都不作数。导出里两条都写——「实际生效: N 个(…)」和「名单里没生效的: …」,并注明停在哪一步(`applied` / `applied-late` / `deferred` / `failed`)。 2. **模型与图像能力**(新文件 `lib/modelinfo.js`)。从会话投影取她那条会话最后一次用的 provider/model(`sessionProjections.snapshot` 挂了就退 `stateOf`),再问 `llm.resolveModelInfo` 要 `inputModalities`:声明了 `image` 是「支持」,只声明文本是「不支持」,拿不到(没有该服务、模型没报、调用抛错)一律「说不准」并写明原因。**三态,不猜**——把「说不准」写成「不支持」,会把排查的人直接带去换模型。 3. **`events.jsonl` 尾部**(`tailEvents`,最近 60 行 / 上限 256KB,脱敏后附)。`watched:` 与看画面的失败记录都在里面,能对上额度是怎么用掉的、卡在哪一步。 4. **看画面每一步都写日志**(`lib/watch.js`)。设置关掉、视频号不合法、今日额度满、抽帧失败四个分支各写一行,成功也写一行(带 `used+1/cap`)。以前这条链路一个字都没有,用户那边出问题时导出包里完全查不出被哪一条挡下。日志给人看,可以写数字;工具回复给她看,仍然不许出现数字。 新增 `test/modelinfo.test.mjs` 8 条(含「模型没报 `inputModalities` 时必须是说不准、不是不支持」),`test/watch.test.mjs` 补 4 条盯日志写入(设置、非法视频号、额度满、抽帧失败)。 **修:状态卡片一分钟跳一条「醒了,但今天没有该问的事」**(用户 m06971:「现在一分钟跳一个记录,这是怎么回事」)。 调度层每 60 秒 tick 一次,候选池一空就写一条 `quiet:` 的 meta 事件记「今天不打扰」。这条事件既没有 `wake:` 前缀(`todayCounts` 数不到、「每天最多几条事件」拦不住它),也没做过「今天已经记过」的判断——所以从 0.1.0 起它一天能写几百条(实测 2026-10-27 有 142 条、10-28 有 96 条),状态卡整页刷成同一句。 两处改法:写入侧一天只记一条(`lib/scheduler.js` 先查 `quiet:<日期>` 是否已在事件流里);读的时候按**本地时区**的日期折叠,旧档里已经堆着的那几百条各天只留第一条(`lib/status.js`)——事件流只能追加,不能靠删历史来修。折叠不能按 ISO 串切前 10 位:`at` 是 UTC,北京时间 07:25 与 08:00 会落在不同的 UTC 日子里,切串就合并不掉。 这一条**不是 0.6.1 引入的**,远端 0.6.1 也有;`growthDays` 只是间接相关(天数推得快 → 快速过阶段 → 候选池提前空 → 更早开始刷屏),`daily.events` 无关。新增 `test/status.test.mjs` 两条折叠用例。 新增 `lib/locate.js`(同步读指针,带 2 秒模块级缓存——`rootDir()` 是同步函数且被 `paths()` 的默认参数高频调用)、`lib/move.js`(`planMove` / `moveRoot` / `moveStatus`)、`test/move.test.mjs`(7 条);`/dsh-fish/move` 的 GET/POST;`lib/store.js` 的 `rootDir()` 改为「环境变量 → 指针 → 默认」。 ## 0.6.1 — 2026-10-28 **叫醒时先说清「现在几点、谁让醒」**(用户 m00974:屋子刚建好那天 16:06 她被叫醒,第一句是「大概不是我的时段」——用户以为是自己没给权限,去翻设置、还试了提权,后来看日志才知道是插件在收窄工具面)。 现在叫醒的处境开头先给时间与缘由(`lib/scheduler.js` 的 `wakeTimeLine`):手动叫醒是「现在是 16:06。有人刚按了叫醒——这个点不在你平时的时段,是有人要你现在醒。」「现在是 20:43。有人刚按了叫醒,要你现在醒。」;定时唤醒是「现在是 20:43,这一次是你自己醒的。」空白屋子第一次醒来走的是另一条路(`wakeIfBlank`,没有处境段),就把时间与缘由直接写进触发语(`lib/wakeconst.js` 的 `freshWakeText`):「(醒一醒。现在是 16:06,屋子刚建好,有人叫了你一声。)」 **身份文档补一句工具面**(`lib/persona.js` 的 `TOOL_BRIEF`):「屋里只有这几样,不是被谁拿走了;别的东西伸手也够不着。」——她原来会把 7 个工具当成权限事故,以为「什么也动不了」。 工具面收窄的**设计**没有改:第一轮放开等于给一个陌生模型一个 shell,也违背「不让她莫名调工具」的原始设计。 **修:启动时工具面被收窄成 1 个**(2026-10-27 另一台真机,0.6.0):日志里是「有 6 个名字宿主还没登记,先按认识的 1 个收窄」——宿主先建了她的 agent、插件的 6 个工具还没登记完,旧实现就把 `allow` 与报错里的已知名单取交集再收窄,交集只剩 1 个。那条会话从此只看得见 1 个工具,她调 `bili_look` 直接 `Error: unknown tool "bili_look"`,于是回答「我没法浏览 B 站」。`restrict` 只能减不能加,收窄错了没有回头路。现在:名字没登记齐就**这一次先不收窄**(兜底 guard 照旧在拦),每 200ms 再看一眼,齐了再收(日志报「收窄延后」);15 次仍不齐就留到下一次会话,自检显示「暂未收窄」,不再假装成功。工具名快照也改成只增不减,防止 1 个名字覆盖全量名单(`lib/restrict.js`)。 **QQ 接入写进说明、白名单念给她听**(用户 m01371):设置页「手上能用什么」补了「QQ 接入」一段——装一个社区现成的 QQ 桥接插件(比如 qq-bridge),在上面白名单填 `qq_send_message` / `qq_get_unread_messages` / `qq_reply`;本插件不直接提供 QQ 桥接功能,两个一起装、在这里放行就可以;不知道装的那个插件实际工具名,可以先问大肥鱼。白名单输入框的 placeholder 也给了同样的示例。身份文档改成 `toolBrief(extra)`(`lib/persona.js`),把 `config.tools.allow` 里的名字念给她听——原来她不知道自己多了这几只手,就算放行了也不会用。 **事件流变长后不再卡住 JS 主线程**(issue #3,bigbigyu-Blue):`deriveSelf` 的 `mentions` 原来对每条判断遍历全部 utterance 并重建字符串,`detectTensions` 两两比,`stability()` 每次抽样又全量重算——事件流到 500 条时一次派生是秒级,观测台与心跳会一起卡住。现在 `stripNegation` 与字符二元组先算一次复用,`mentions` 与 `detectTensions` 改成倒排索引只算「共享至少一个二元组」的候选对,`stability()` 按「事件流指纹 + 参数」缓存(新增 `clearStabilityCache()` 供测试用);判定口径、同分顺序与输出都没变。 **提示词不再承诺取不到的素材**(issue #4,同一作者):`first_bet` / `test_first` / `why_say` 三条模板里写死了「(下面附素材)」,但不在上网时段、今日次数用完、风控冷却、网络失败时素材本来就没有——她会收到一句「下面附素材」,下面什么都没有。现在模板只描述处境(「今天可能有点东西摆在你面前,也可能什么都没有」),真取到素材时仍由 look 段自己说明。 ## 0.6.0 — 2026-10-27 **领养人和登录账号拆成两个人**(用户 m00551:「导入 cookie 的时候会顺带把账号名自动填入『领养人认证』,而且不允许更改,能否让 cookie 归 cookie、领养人单独填写——这样我可以用小号放 cookie,大号接收艾特」)。 以前只要存一次 cookie,或者点一次「测试能不能说话」,插件就会拿这份 cookie 探到的 mid 与昵称覆盖「领养人」那两格。拿小号当她的嘴时,评论里 @ 的是小号、自检里显示的是小号、用户手填的大号下次保存又被冲掉。 现在 `owner.json` 多一格只读的 `account`(`mid` / `name` / `savedAt`):「这份 cookie 登的是谁」只记在这里,存 cookie 和测试都只写它,绝不碰 `mid` / `nickname`。领养人只由用户手填,决定 @ 谁。判断「哪条评论是她自己补的」改用 `account.mid`(`lib/comment.js` 的 `loginMid`),老数据没有 `account` 时仍退回 `owner.mid`。 界面:领养人卡片写明「这是要 @ 的账号,一般填大号,和 cookie 无关」;cookie 卡片只读显示「登录账号:xxx(uid …)」;点「测试能不能说话」不再把输入框改成小号。自检的「B 站登录」显示登录账号,而不是领养人昵称。 **重置后的首批不再固定四条**(用户 m00763:「那个首次刷视频,不要都去刷入站必刷榜了……阶段 1 混入排行榜/分区,不再只吃一份固定片单,天然就分散」)。以前阶段 1 只吃「入站必刷」——98 条全站老片的固定榜;而重置会清空事件流(seen 变空、阶段回 1),所以每次重置刷到的首批都是同样四条。现在阶段 1 改成老片 + 热门 + 全站排行 + 轮换的分区排行四路混(`lib/browse.js` 的 `SOURCES[1]` 与 `RANKING_TIDS`,分区数由 `browse.rankingTids` 控制、默认 2);候选进 `rankVideos` 之前先 Fisher-Yates 打散,分数并列时的先后不再固定成接口顺序。分数不并列的仍按分数排——打散只负责「同样值得看时不再总是同一批」。这两笔一起并进 0.6.0 发布(0.5.10 没有单独发行)。 ## 0.5.9 — 2026-10-27 **「判断漂移」原来那把尺子量错了东西**(用户 m05785:「稳定 0.00 · 一致 1.00 · 新鲜 0.00,为什么剩下两个全是 0,是不是这 3 个数字都是 1 最好」)。 三个数里有两个是**样本不足时的默认值**被当成读数印了出来:刚重置的一局里她还没有判断(稳定 → 0)、说过的话不足两条(一致 → 1)、没有七天以前的老话(新鲜 → 0)。更要紧的是稳定那把尺子本身有问题——它量的是「抽掉几条判断后 `needs` 三项变多少」,而 `needs` 是拒绝过几次、改口过几次、重聚过几次这些**内部计数器**的映射,不是「她这个人」。刚开局的号里三项常年 0,抽掉谁都是 0,于是稳定永远是 0.00。一个永远为 0 的尺子等于没有尺子。 这次动三件事(用户 m05868:「该测的是「少给她几条她信的事,她还是不是她」,123 都做」): - **① 换尺子:稳定 = 抽掉几条她还在拿着的判断,她还是不是她。** 新增 `lib/quality.js`(依赖方向 quality → derive + persona,不能再塞回 derive,否则 `selfdoc.js → derive.js` 那条已有边会绕成环)。做法是随机抽 k 条判断(连同源头事件),重算一遍她的自我描述,和原来的比相似度:接近 0 = 那些判断只是记账,抽掉几条她一个字都没变;接近 1 = 全押在少数几条上,少一条就是另一个人。比较前先剔掉「你是谁 / 现在 / 不能做的 / 你还不会的 / 他说的事和你记得的不一样时」这些**永远不变**的骨架段,否则分母被常数撑满,真实变化全被稀释成 0.0x。 - **② 样本不足时不再印假分数。** `stabilityBand()` 把「还没攒到判断,测不出来 / 只有 N 条判断,抽掉一条就是一大块 / 这些判断还只是记账… / 长在这些事上… / 全压在这几条上…」写成一句人话;客户端对一致与新鲜同样处理(「还没有跨阶段的话可比」「最近一周没说过话」「还没有七天前的老话可比」)。数字只在真能测时才印。 - **③ 界面文案改口。** `QUALITY_HELP` 原来写「越接近 1 越好」——错的。这三个不是考卷分数,是体检值,稳定太高也是病(押得太死)、一致恒为 1.00 也可能是她一直没长新东西。 另:`lib/derive.js` 里的 `stability` 移除(旧尺子),改由 `lib/quality.js` 导出同名函数;`lib/observe.js` 的 `/state` 直接把 `band` 一并算好传出,客户端不含阈值(阈值只该有一处)。 **它的三个值现在分三行显示**(用户 m06107:「这三个参数分成三行显示,现在排成一行有点拥挤」)。原来三个值拼在同一条 `rowTip` 的值里,稳定那句判读又长——一长串挤在一行里,读的人分不清哪个是哪个。现在「判断漂移(三个诊断值,不是分数)」占一行做小标题,下面三行各自是「稳定 / 一致 / 新鲜」,每行右边一个 ⓘ 说清这一项到底量什么。版本号不升,仍是 0.5.9。 新增回归 1 条(`test/client.test.mjs`):三个值必须是三个独立的行,不许再拼成一行。 **能测出来的时候,数字要跟判读一起印**(用户 m06350:「为啥我重启了之后,界面还是显示的长在这些事上,抽掉一条少一块,就没有给数值啊」)。三行改完之后,`qStability` 让 band 直接把数字盖掉了——能测出 0.16 的那一局,界面上只剩「长在这些事上,抽掉一条少一块」,读的人以为根本没测。现在改成 `0.16 · 长在这些事上,抽掉一条少一块`:数值在前、判读在后。样本不足时仍然只给判读(那种 0.00 是默认值,不是测量结果,不能当数字印)。改的是同一条回归用例的断言:从「有 band 时不该印数字」改成「能测出数值时数字必须印出来」。 回归 634 条:632 过、2 条已知跨文件假红(`cover: fetchCover 拿不到图时返回空串,不抛`、`ULID 单调递增,且能从 id 反解时间`,单文件跑各自全绿)。 ## 0.5.8 — 2026-10-27 **「回顾」这一步开始要凭证**(用户 m05409:「那就动第一层和第二层,回顾也允许她说我想不清楚。第三层不做」)。 背景是另一只大肥鱼对项目的评价里最准的一条:事件流无限增长,真正进上下文的只有 `deriveSelf` / `persona` 那层摘要,所以「摘要」就是她的自我;而 LLM 太擅长把零散经历讲成一个漂亮的成长故事。它担忧的是**叙事漂移**——不是她说谎,是每次读到自己的摘要都顺着它说,说完又追加成新事件,三十天后方向已偏。 这次动的是两头: - **第一层:refs 要能对上,不只是写着。** `lib/schema.js` 的 `validateEvent` 新增 `knownIds` 参数,`revise` 和 `narration` 的每个 ref 都必须在事件流里真实存在,否则 `REF_NOT_FOUND`。以前只查「非空」,于是「我改的是哪一条」很容易退化成仪式——随便填个 id 就过了。`lib/store.js` 的 `appendEvent` 在 `type` 是这两种时读一遍全量事件、把 id 集合塞进 ctx;读失败就退化成老行为(`knownIds = null`),不能因为查不了就拒收她的正常写入。 - **第二层:回顾型 `narration` 也要 refs。** 以前 narration 随便写,于是「我变成了什么样」可以凭空编。现在要么用 `refs` 指到 `her_recall` 翻到的具体几条,要么直说「我没想清楚」。**「我没想清楚」是放行的**——那句本身也是真的,不该因为没想清楚就什么都不许写。判定用 `isBlankNarration()`:必须是 60 字以内、且整句就是一个「没想清楚」的说法(`我想不明白` / `说不清` / `想不出什么` / `我还没想好` 等);拿它当开头再接一整篇自传不算,仍要 refs。 - **`her_recall` 现在每条都带 id**(`lib/recall.js` 的 `searchEvents` 加 `id` 字段、`renderRecall` 在正文后用括号附上)。没有 id,第二层就只是逼她编。`RECALL_BRIEF` 常驻身份文档的那段也加了一句「每条后面小括号里是它的 id——要改口、要做回顾时,用它指回去」,否则她会以为那是乱码。 - `lib/persona.js` 的 `sleepPrompt` 与常驻的 `SLEEP_BRIEF` 同步改:要求 refs,并写明「真的没想清楚,直接写「我没想清楚」——那句也算数,不用带 refs」。只改周提示不改常驻做法的话,平时那一轮她根本不知道有这要求。 - `lib/capture.js` 的 `growth_commit` 工具说明与 `refs` 参数描述跟着改。 **第三层(要求每句自我认知都挂凭证)不做**,理由:很多「我变了」是弥漫性的,硬挂凭证会让回顾变成拼凑,产出变少而质量更难判断。 新增回归 5 条(`test/schema.test.mjs`):refs 指不存在的 id 被拒 / 不传 knownIds 时保持老行为 / narration 无 refs 被拒但 5 种「没想清楚」放行 / 「我没想清楚」+ 长自传仍被拒 / utterance 不受影响。「她不能用工具写系统侧类型」那条随 narration 新规则补了 refs。 回归 630 条:628 过、2 条已知跨文件假红(`cover: fetchCover 拿不到图时返回空串,不抛`、`ULID 单调递增,且能从 id 反解时间`,单文件跑各自全绿)。 ## 0.5.7 — 2026-10-27 - **「下载交接单模板」按钮挪出来,任何时候都能看到**(用户 m05086)。原来它挂在「这一局还能接、并且已经展开粘贴框」那一个分支里——已经养着一只鱼的人(`canImport` 为假)走的是另一条分支,页面上根本没有这个按钮。用户原话:「我没在导入其他人的大肥鱼那里看到这个交接单模版下载的按钮」。 - 现在按钮提到卡片正文末尾,**跟「这一局能不能接」完全解耦**:能不能接、要不要接、有没有展开粘贴框,都不影响下载。旁注一句「不管这一局能不能接、要不要接,模板随时能下载」。 - 「不能接」那条分支的说明补一句「模板可以先下载下来备着」,免得人以为没戏了。 - 顺手把 `importNote`(下载/导入的结果提示)也提到这一层,下载成功或失败的反馈在任何分支下都看得见。 - 「粘贴交接单」按钮保持不变,仍只在能接的时候出现——那一支才需要贴。 回归 625 条:623 过、2 条已知跨文件假红(`cover: fetchCover…`、`ULID 单调递增…`,单文件跑各自全绿)。 ## 0.5.6 — 2026-10-27 - **把「接过来什么、不接什么」写成用户能听懂的话**(用户 m04402)。原来说「不过来的:过了多少天、长出了哪些判断、身上的疤、拒绝过什么」,太短,没说清后果——现在逐条写明: - 天数:本插件按时间推进事件,非本插件原生的那一只要从第一天重新算。 - 判断:计数为 0。原提示词里规定的一些认知她依然记得,但往后在本插件的事件循环里可能被更新、优化、忘掉一部分。 - 疤、拒绝记录:同上。 - 日记、B 站登录 cookie:不带。 - **新增一段说明**:如果你原本的大肥鱼带了好感度这类数值设定,进入本插件后她看不到自己的好感度,本插件也不允许你通过命令改她的人设——原来可以为所欲为,到这里关系只能靠真实交互推进,也没法随口命令她换一种人设。这段话是**说明**不是控件,`test/client.test.mjs` 那条「不该出现好感度/亲密度」的红线扫描会先把这一段剥掉再扫(剥不掉就红,防止哪天这段被悄悄删掉)。 - `README.md` 开头那句改成:「本项目的人格并非角色扮演的形式,没有人设配置菜单。默认从空白开始养起,但可导入你已经养过的大肥鱼人设提示词,继承大肥鱼的人设并在本插件环境内进行成长,具体请看本插件「数据」页面介绍。」 - `README.md` 的「不会过来的」一段同步改细;并新增「如果你带了好感度这类数值设定」一段。 - `market/ssamurias__dsh-fish.yml` 的中英文首段同步加上「也可以导入你已经在别处养过的大肥鱼人设提示词,继承她的人设在本插件里继续成长」(英文首屏仍在 160 字符内,句子完整)。 - **交接单模板可以一键下载**(用户 m04565)。设置页「数据 → 导入人设」多了两个按钮:「下载交接单模板」把一份 `.md` 存到本地,「粘贴交接单」才是真正导入。模板不是给人照填的表格,是**写给对面那个 AI 的说明书**——上半写明怎么写(第二人称、只写已经稳定成型的样子、不写形容词清单)、不要写什么(不写好感度、不写剧情、不写"你总是很温柔"这种笼统画像),下半才是「你是谁 / 称呼 / 工具」三节空表。把它发给现在养着她的那个 AI,它填完你再把填好的部分贴回来。 - 解析时说明书的部分一个字都不能进她的人设:整份模板贴回来(还没填)会被判为「空」,填好之后说明书段落也会被丢掉。`test/import.test.mjs` 加了 3 条把这件事钉住。 - `README.md` 顶部加了视频讲解链接(19 分钟,B 站 BV1oTnZ6LEtc)。 - **交接单写明了字数区间**(用户 m04868)。原来模板里写的是「2000 字以内(上限 6000 字)」,那句是错的:事件流的正文上限就是 2000,超了直接拒收;而 1800 字的身份预算里人设占满之后,「判断、疤、立场」这些往后会长出来的段落会被整段挤掉。现在模板、设置页导入卡、README 三处统一写成「1200~1500 字」,并说明超了会怎样。`test/import.test.mjs` 加一条把这三个数字钉在模板里。 - **交接单里的小节名前后对不上了**(用户 m05009)。模板的「怎么写」那段说的是「『你自己』这一节用第二人称写」,但下面真正的空表标题是 `## 你是谁`,解析器认的也是「你是谁 / 人设 / 设定 / 她是谁 / 交接单」。照模板填完贴回来,`### 你自己` 这行会被当成人设正文收进去,变成她身份文档里的一行标题。两处都改了:模板正文统一成「你是谁」,解析器额外把「你自己」也认了——别人手里的交接单不一定按我们的模板写,多认一个词比少认一个词安全。新增一条回归。 回归 624 条:622 过、2 条已知跨文件假红(`cover: fetchCover…`、`ULID 单调递增…`,单文件跑各自全绿)。 ## 0.5.5 — 2026-10-27 - **修「每次重启都再叫醒她一次」**(issue #1 报的第二个问题,@xidojiu 补充)。0.5.4 之前的 `wakeIfBlank()` 靠 `list()` 猜这条会话是不是空的:查不到这一行、且 `freshOnly` 为真,就当空白叫一声。问题在插件自己建的会话上**永远**是这个形状——它不会被设成「当前会话」,宿主也就不把空的非当前会话放进可见列表——所以这条分支每次启动都命中,每次重启都白叫一声。 - 改法:不再猜,**自己记账**。叫成功之后把这一条 `sessionId` 写进 `home.json` 的 `wokeFor`;同一个 id 再来直接收手,返回 `why: '这一局已经叫过了'`。 - 只记**成功送达**的那一次:宿主的 `prompt` 收下这一轮就 resolve,收下就一定会跑出内容;抛错则不写,下次启动还会重试(0.5.4 之前漏传 signal 那种情况不会留下假标记)。 - 换会话(`rotateTalkSession` / 重置后重建)就是换了 id,自然重新算一次。 - `root` 为空时不记账——测试与只读调用回到原来的行为,不写文件、也不挡第二次。 - `wakeIfBlank` 新增 `root` 参数,两个入口(`lib/index.js` 启动、`lib/observe.js` 的 `POST /reset`)都传进去。 - 新增 4 条回归:同一条会话不会被叫第二次 / 换了会话重新算 / 没给 root 不记账 / `prompt` 失败不记账下次重试。`test/wake.test.mjs` 14 条全绿。 回归 622 条:620 过、2 条已知跨文件假红(`cover: fetchCover…`、`ULID 单调递增…`,单文件跑各自全绿)。 ## 0.5.4 — 2026-10-27 - **修「豁免会跟着备份/恢复跑」的漏洞**(用户 m03769)。0.5.3 把「这一位是接来的,不受那两条提示词禁令约束」写成了一个配置项 `persona.bansMuted`,存进 `config.json`。问题是 `config.json` 会跟着**备份与恢复**一起被搬回来,也会被重置时抄回新库——结果一条原生养大的鱼,只要恢复过一次「导入过」的档,就永久拿到了豁免。用户原话:「检测到是使用接一只别人养过的大肥鱼这个导入的,就忽略禁令,通过我们自己插件的备份与恢复的就不忽视」。 - 改法:豁免凭据从配置挪进**事件流**。导入时多写一条 `meta` / `import-mark:<来源>`;`deriveSelf` 解析出 `model.persona.{imported, source}`;身份文档(`lib/selfdoc.js`)与观测台(`lib/observe.js` 的 `/memory`)判定时**只读 model,不读 config**。 - 事件流只能追加:原生那一局从头到尾没有这条记录,恢复多少次也不会长出来。 - 重置时整个状态库被 rename 走,新一局天然没有这条——`reset.js` 里原来那个 `clearImportedMark` 调用随之删掉(不再需要)。 - 删掉 `DEFAULTS.persona`(整组)、`clearImportedMark()`、以及 `/import` 里写 `persona.source` 的那段;来源改为作为参数直接传给 `applyHandover`,与标记一起写进那条 `meta`。 - `/import` 的 GET 改为从事件流算 `persona`,不再回显配置。 - 新增 4 条回归:凭据写在事件流里 / 原生局没有豁免 / **配置里写着豁免也没用(模拟恢复来的 config)** / 重置后新一局没有豁免。`test/import.test.mjs` 13 条全绿。 - 设置页导入卡加一句:「这个豁免只认从这里接过来这一个动作,备份恢复搬回来的一分豁免都不会有」;README 的导入小节同步。 回归 614 条:612 过、2 条已知跨文件假红(`cover: fetchCover…`、`ULID 单调递增…`,单文件跑各自全绿)。 ## 0.5.3 — 2026-10-27 - **新增「接一只别人养过的大肥鱼」**(用户 m03170 / m03201)。原来的承诺是「没有人格设置」,现在的说法是「**默认没有人格设定,但支持导入**」:默认还是一张白纸、靠事件流长大;如果你手里有别人养过的交接单,可以把它接过来,让她从那份设定继续长。 - 接过来的三样:**人设正文**(写进 `seed`,成为「你是谁」)、**称呼**(写进一条 `meta` 事件,`import-name:` 前缀)、**工具白名单**(与现有取**并集**,不会挤掉她自己的那 6 个工具)。 - 不过来的:天数(**从第 1 天开始**,`growthDay` 只认 seed 的时间戳)、判断与疤、拒绝记录、日记、B 站登录、每天刷几次的配置。 - 带过来的设定**不受我们那两条提示词禁令约束**(「不可以用亲密度/好感度这类词」和「还不会撒娇卖萌」)。实现是 `selfdoc.js` 的 `withBans: false`,物理层两条(不读自身数值、不改自己)仍在工具层拦着,不豁免。 - 只在**还没起点**(没有 `seed`)的一局生效。已经有起点时接口返回 409 并让他先重置——覆盖一个已经在长的她,这个决定不该由插件替他做。 - 带过来称呼之后,我们第一天的起名事件 `name_offer`(条件是 `!m.name`)**自然不再触发**,不需要额外代码。 - 重置时 `clearImportedMark` 会把 `persona.imported/bansMuted` 清掉——否则会出现「默认人设 + 别人的豁免」这种最糟的组合。 - 新增 `lib/import.js`(`parseHandover` / `applyHandover` / `clearImportedMark` / `IMPORT_TEMPLATE`)、`POST|GET /dsh-fish/import` 路由、设置页「数据」页签里的导入卡,以及 `test/import.test.mjs` 11 条用例。 - 交接单里的 `# 交接单`「## 你是谁」只是外壳标题,解析时丢掉、只收正文——否则身份文档里会叠出三层重复标题。 - `persona` 这组配置**不进 `/config` 白名单**:它只由导入动作写,不给一个「关掉那两条禁令」的开关。 回归 612 条:610 过、2 条已知跨文件假红(`cover: fetchCover…`、`ULID 单调递增…`,单文件跑各自全绿)。 ## 0.5.2 — 2026-10-26 - **修「她怎么叫我」永远不更新**(用户报告:已经让她改叫「时梦尘」,她答应了,设置页状态栏还是「还没想好」)。 两处都是识别规则写窄了,不是她没记住: - 旧规则只认「称呼你 / 叫你」。真机上她写的是「**他给了自己的名字:时梦尘**,说他认这个名字。以后叫他时梦尘。」——三种说法一种都没命中。现在「称呼他 / 叫他 / 称他」和「他(给了)自己的名字:X」都认,但**不认「他给我起了名字:大肥鱼」**(那是她自己的名字)。 - 旧规则只取第一次命中的。她先猜过「搭屋的」「开灯的」,被否掉之后才定下他给的名字,于是永远停在最早那个上。现在**后来的盖掉先前的**,以最近一次为准。 - 补了句尾剥壳(「我叫你老张好了」→ 老张)和代词挡板(「以后叫他他吧」→ 不算);`的` 不剥——「开灯的」剥掉就成了「开灯」。 - 顺带改掉三处过时注释(`lib/scheduler.js`、`lib/store.js`、`lib/readme.js` 里还写「身份由 home/AGENTS.md 承载、宿主每轮注入」):0.4.x 起身份走 `systemPrompt.section`,`AGENTS.md` 那个通道早已不用,`selfdoc.js` 还会把它 unlink 掉。 回归 601 条:599 过、2 条已知跨文件假红(`cover: fetchCover…`、`ULID 单调递增…`,单文件跑各自全绿)。 ## 0.5.1 — 2026-10-26 对外措词统一成用户视角(市场简介、README、插件内说明、技术规范同一套说法): - 「她的人格不是配置出来的」→ **没有人格设置,通过事件流长大**。 - 「没有设置面板」→ **没有人格设置**。 - 「用自己写的客户端刷 B 站」→ **她可以自己刷 B 站**。 - 「事件流只追加,是唯一真相源」→ **事件流不能删除记录,是唯一可信源**。 - 「会写不给自己看的日记」→ **会写日记给你看**(`diary.visible` 默认仍是关的,打开开关就能读;文案与设置页说明同步改成这个意思)。 - 「四个阶段全部走完之后才开始攒立场清单」→ **四个阶段完成后才会开始有立场**。 改动文件:`package.json` 描述、`README.md`(事件流与 stance.js 两行)、`lib/readme.js`、`AGENTS.md`(第 4、5 节)、`lib/client.js` 日记说明、`lib/browse.js` 与 `lib/schema.js` 注释。**不涉及任何行为改动**,回归 600 条里 598 过、2 条已知跨文件假红。 ## 0.5.0 — 2026-10-26 - **修「图片无法预览」**(用户 m01408:「为什么我在和大肥鱼对话的时候,提示我图片无法预览」)。 根因是**反斜杠路径**,不是文件缺失、不是权限、也不是跨目录读取。同一张图做了三组对照(2026-10-26 实测): - `![x](C:\Users\me\.dsh-fish\media\sheet-….png)` → 显示「图片无法预览」 - `![x](C:/Users/me/.dsh-fish/media/sheet-….png)` → 正常显示 - 同一文件复制到工作区内、用正斜杠 → 正常显示 宿主渲染 markdown 图片时把路径拼进 `/api/file?path=`,反斜杠在那一层取不到文件; 跨目录读取本身没有问题(宿主文档明确写着「不限制目录包含关系」)。 她照抄工具给的路径,所以**插件交给模型的路径一律改成正斜杠**:新增 `lib/posix.js` 的 `slashed()`, 用在 `bili_watch` 的返回(`lib/watch.js`)、拼图路径(`lib/video.js`)、封面 markdown(`lib/cover.js`)、 「他发了张图,在这儿:…」(`lib/say.js`)。内部读写文件仍用 `node:path`,行为不变。 **注意:历史消息里已经写下的反斜杠图不会变好**,只有从这一版起新生成的消息能显示。 - 新增 `test/posix.test.mjs`(2 条)+ `coverLines` 的「不含反斜杠」断言; 全量 `node --test --test-isolation=none` = 600 tests / 598 pass / 2 已知跨文件假红 (`cover:fetchCover`、`ULID 单调递增`,单文件跑都绿)。 - **公开发布前的清理**:仓库里出现过的本机用户名统一改成 `me`(`lib/posix.js` 注释、`test/posix.test.mjs` 的三处断言、本文件里的两行示例);`.gitignore` 补上 `owner.json` / `config.json` / `.dev`。 历史提交逐条查过:只有测试用的假凭据(`SESSDATA=abc123`、`abcdef123456` 这类),**没有真实 cookie**;`lib/owner.js` 是读取与打码逻辑,不含任何账号数据。 ## 0.4.9 — 2026-10-26 - **改掉重置后那句不诚实的提示**(用户 m01426 真机反馈:「上面说什么重置了,应该能在侧边栏看到对话了,但是我实测不行,还是要点叫醒大肥鱼才行」)。 0.4.3 起重置会顺手建新会话、还会 `wakeIfBlank` 叫一声,提示就写成「侧栏里应该已经冒出「跟大肥鱼说话」」—— 但宿主的可见性规则是 `if (session.blank && session.id !== current) return false`,插件用 API 建的会话 **永远不会成为 current**,所以自动叫一声并不保证它出现。实测只有人自己点「叫醒大肥鱼」才会出现。 现在三处文案统一改成「点一下「叫醒大肥鱼」,她开一次口,侧栏里才会出现这条会话」: - 重置按钮上方的说明(`lib/client.js` 重置卡) - 点完的即时提示(`doReset` 的 `tail`) - `/reset` 返回的 `note`(`lib/observe.js`,给不走前端的调用方) 行为没改(仍然尝试自动唤醒,成功算白赚),只是不再承诺侧栏会自己冒出。 ## 0.4.8 — 2026-10-26 - **重置不再把配置也恢复出厂**(用户 m01201:「不仅仅是cookie不变,那个配置也应该不变,就是包括上网、成长的相关设置」)。 `resetAll` 的 `CARRIED` 从 `['owner.json', '.dev']` 扩到 `['owner.json', 'config.json', '.dev']`:重开一局搬走的是 她经历过的事(事件流、日记、屋子、备份),不搬「这台机器怎么待她」——每天醒几次、上网时段与额度、成长节拍、 日记可见性都原样留着。备份里同样还有一份。 - 回归补强:`test/p4.test.mjs` 的「重置把钥匙留下」用例改成同时断言 `owner.json` 与 `config.json` (`wakesPerDay` / `browse.perDay` / `memory.sleepEveryDays`)在新库与备份目录里都在。 - **日记栏一天一页,多了自动出翻页**(用户 m01201:「每天的日记都单独归类」「我倾向于自动出现翻页按钮」)。 原来观测台一次把 30 天正文全塞进一个 `
`,日子一多既慢又没法看。现在:
  - `/diary` 默认只回 `dates`(日期目录,新→旧)+ 最新那一天的正文;`?day=YYYY-MM-DD` 单独取一天,
    那天没有就 404。不可见时仍然只给 `lockedDates`,开关语义没变。
  - 页上多天时出现「← 更早 2026-10-20 1 / 12 更晚 →」,单天时只显示日期;翻过的页缓存在内存里,
    30 秒轮询只刷新当前页,不会把正在看的那页弹走。
  - 新增回归 1 条(`test/boot.test.mjs`「日记可见时一天一页:默认只给最新一天,?day= 取指定那天」):
    默认只带一天、指定 `day` 只回那天(不混进别的日子)、不存在的日子 404。
- 全量 `node --test --test-isolation=none` = **598 tests / 596 pass / 2 fail**,两条仍是已知跨文件假红
  (`cover:fetchCover…`、`ULID 单调递增…`,单文件跑都绿)。

## 0.4.7 — 2026-10-26

- **叫醒消息不再派活**(用户 m00928 原话:「叫醒内容太多,就给改到常驻身份文档」)。
  原来一条唤醒消息里连着两段祈使句手册:`diaryPrompt` 的「今天写日记…写今天发生了什么、你注意到什么」
  和 `sleepPrompt` 的「这一周过去了…不要总结」。她在 `fish-wake-2026-10-26-manual` 里的 reasoning 是
  「需要做的是:说一句…然后写日记」——那不是醒来,是领工单。
  现在:**做法**常驻在身份文档(`persona.DIARY_BRIEF` / `SLEEP_BRIEF` → `selfdoc.renderSelfDoc` 的
  「## 平常会做的事」两节),**时机**只留一句 `SLEEP_TODAY`(「(今天停下来,想一想。)」)。
  日记提示整段从唤醒消息里删掉——她每天都记,不需要每次醒来被念一遍。
- **新增「让她去刷 B 站」按钮,仅本机测试版**(用户 m00928:「仅在本机的测试版生效…我需要用这个按钮
  来测试刷 b 站功能能不能生效,正式版不会有这个按钮」)。
  判据是状态库根目录下的 `.dev` 文件(`lib/devmode.js`):在则 `/state` 返回 `dev: true`,设置页状态卡
  多出一个按钮;不在则前端连节点都不生成。`.dev` 进了 `resetAll` 的 `CARRIED`,重开一局不会把测试开关弄丢。
  按钮走 `POST /wake?focus=browse`:跳过 `plan()` 的「今天该不该醒」,并让 `lookBeforeWake` 绕过上网时段与
  `perDay`(`deps.forceBrowse`)——不绕过风控,那不是功能开关。
- 新增回归 3 条(`test/nudge.test.mjs`):forceBrowse 在时段外/额度用完时仍然去取素材;同配置没有 forceBrowse
  时一次都不发(证明闸门没被顺带拆掉);`devMode` 只认 `.dev` 文件。

## 0.4.6 — 2026-10-26

- **修掉「她刷不了 B 站」的真凶**(用户 m00751 的现场证据):`lib/look.js:47` 原来是
  `(await readEvents(root).then((r) => r.events)).catch(() => [])` —— 括号把 `await` 一起圈进去了,
  `.catch` 就落在**已经 await 出来的数组**上,数组没有 `catch` → `TypeError: (intermediate value).catch is not a function`。
  工具把它当成"这次没看到什么"返回给她,她连试两次都只得这一句,日记里还把它写成了"世界没让我看"。
  现在改成 `await readEvents(root).then((r) => r.events).catch(() => [])`,实测首页三条正常出来。
- **重置不再把 B 站 cookie 一起封存**:`resetAll` 把整个 `~/.dsh-fish` rename 到备份,
  `owner.json`(SESSDATA 等登录凭据)也在里面,于是「重开一局」之后她当场变成未登录。
  现在 rename 之后把 `owner.json` 抄回新状态库(`CARRIED = ['owner.json']`),备份里那份照旧保留。
  理由:她是换了一辈子,不是换了台机器——钥匙跟人走,记忆不跟。
- 新增回归 1 条:重置后 `owner.json` 在新状态库里、且封存目录里仍有一份(`test/p4.test.mjs`)。

## 0.4.5 — 2026-10-26

- **叫醒不再把对话扔进「未分组」**:`wakeNow` 原来硬编 `fish-wake-<日期>-manual-` 当会话 id,
  宿主不认识这个 id,就按 cwd 另建一条 —— 那条会话不在任何工作区的 `sessionIds` 里,侧栏只能丢进「未分组」,
  用户在「大肥鱼的家」里根本看不见(m00507)。现在先 `talkSessionId` 取屋里那条会话,真拿不到屋子才退回独立会话,
  并且 `log` 写明「会落进未分组」。
- **触发语从「……」改成「(醒一醒)」**:她真是把它当成用户发来的一句话去猜。
  会话 `fish-wake-2026-10-26-manual` 的 reasoning 原文:「用户发来的只有一个省略号,"……",没有实质请求。」,
  侧栏标题还被 first-prompt 模型生成成 `Error`。现在触发语是括号旁白;`scheduler.js` 另加 `WAKE_HINT`
  进 `fish:wake` 段:「对话里那条括号里的短句不是用户说的话,是叫醒你的信号」。
- 身份文档新增「## 被叫醒的时候」一节(`selfdoc.js`):说明括号短句是系统信号、不是用户的话。
  必须常驻在身份里——手动唤醒走 `wake()` 会带 `WAKE_HINT`,但启动时的 `wakeIfBlank` 不走那段,
  只放对话里盖不住两个入口。
- 新增 2 条回归:有屋子时必须落在屋子会话里、不得再造 `fish-wake-*`;屋子没了才退回独立会话且必须 log。
- 已知限制:宿主没有把已有会话挂回工作区的接口(`workspace/*` 只有 create/delete/rename/archive/pin),
  已经掉进「未分组」的两条旧会话只能右键归档,挪不回来。

## 0.4.4 — 2026-10-26

- **修掉「重置完看不到新会话」的真正原因**:宿主的侧栏可见性规则是
  `if (session.blank && session.id !== current) return false` —— 空白会话只在它是当前主视图会话时才显示。
  插件用 API 建的会话不会被设成当前会话,于是它一直藏在列表外面,点多少次重置都「没有新会话」(m00331)。
- `lib/home.js` 新增 `wakeIfBlank(ctx, { sessionId, freshOnly })`:查 `sessionController.list()` 的 `blank`,
  确实空白就发一轮 `WAKE_TRIGGER`(与 scheduler 同一句),让她动起来;有内容则一个字不说。
  判断失败时保守降级:只有刚建出来的会话(`freshOnly`)才按空白处理。
- **只在两个入口叫**:`index.js` 启动、`observe.js` 的 `POST /reset`。
  不放进 `bootstrapHome`——`talkSessionId` 在屋子缺失时也会调它,那样每次「第一次说话」会多叫一次,
  她一轮被点两次火(`test/p3.test.mjs` 的「说一句话」正是这么红的)。`POST /reset` 的 `fresh` 一并带出 `woke`。
- 唤醒语挪到 `lib/wakeconst.js`:`scheduler.js` 已经 import 了 `home.js`,反过来 import 会成环。
- 新增 `test/wake.test.mjs`(6 条):空白才叫、有内容不叫、查不到按有内容、list 抛错不穿透、
  缺服务明确降级、prompt 失败只记日志。

## 0.4.3 — 2026-10-26

- 重置后**立刻**开好新会话:`POST /reset` 在「搬状态库 + 归档旧会话」之后调用
  `bootstrapHome(ctx, { root, preset })`,返回值多带 `fresh: { wsId, sessionId }` / `freshError`。
  此前新会话只在 DSH 启动时(`bootstrapHome`)创建,所以点完重置侧栏是空的,用户不知道该去哪说话(m00260)。
- 前端重置提示改为「侧栏『跟大肥鱼说话』已经开好,点进去就能说话」,重置卡文案同步。
- 事实核对(写进回答,不进代码):宿主 Session 行「...」菜单只有 分叉/归档/取消归档/置顶/取消置顶/复制 Session ID/重命名,
  **没有删除会话**;能删的只有「删除工作区」,且它只把工作区移出列表、会话归到「未分组」。

# 更新日志

这个插件的版本号含义很简单:**改了事件格式 = 次版本号进位**,其余都只是功能与文案。

---

## 0.4.2 · 2026-10-25

一句话:**「重开一局」现在真的干净了,暗色模式下的下拉框也看得见了。**

### 修复

- **重置之后侧栏还留着旧对话(用户报告)**:`resetAll` 只搬 `~/.dsh-fish/`,而宿主把工作区和会话登记在
  `~/.dsh/storages/workspace.json` 里——重启也不会变,因为那个注册表压根没被碰过。
  现在重置成功后会把「大肥鱼的家」里登记的所有旧会话交给宿主的 `workspaceRegistry.archiveSession(sid, { stopActivity: true })`:
  它们从工作区主列表移进「已归档」,文件一个字节不删;工作区保留(她下次启动还要住这间屋子)。
  宿主没有「删除会话」的接口(已核实只有 archive / unarchive / pin),所以能做到的边界就是归档。
- **暗色模式下所有下拉框变成白条、看不见字(用户报告)**:`select` 的背景写的是 `transparent`,
  原生控件在深色主题里用 UA 白底绘制,而字色是 `--dsw-alias-label-primary`(深色下接近白)——白底白字。
  改为 `--dsw-alias-bg-layer-1` 不透明层色 + `color-scheme: inherit`(跟随宿主在 `` 上设的 dark/light),
  并给 `option` 显式补同组底色/字色。`input[type=time]` 同样的毛病一并改。
- **设置页的重置文案说了假话**:原来写「你在 DSH 侧栏那个会话也会一起被封存」,
  实际根本没碰宿主侧。现在写的是真的:旧对话移进「已归档」、工作区留着、下次启动是新会话。

### 工程

- 新增 `lib/retire.js`(`readHomeRecord` / `retireHomeSessions`)与 `lib/observe.js` 的 `/reset` 串联:
  resetAll 之前先读 `home.json`(之后它就被搬走了),成功后归档;归档不完整只回 `retiredError`,不让重置失败。
- 新增 `test/retire.test.mjs` 7 条:全量归档去重、home.json 被搬走时用提前读的记录、无 registry 时明确降级、
  老宿主单参数降级、单条失败不抛。
- 测试数 575 → 582。

---

## 0.4.1 · 2026-10-25

一句话:**把插件页的顺序改回来——原来的工具说明在前,「关于」在最后。**

### 修复

- **插件页三张卡的顺序**:现在是 ①手上能用什么 → ②为什么默认只放这几个 → ③关于(版本 / 检查更新 / GitHub 入口)。
  0.4.0 把「关于」卡插在了工具卡后面、工具说明前面,把这一页原来的阅读顺序打乱了——工具卡和它下面那段"为什么"本来是一件事(能看到什么工具、为什么只有这几个),不该被拆开。
  没有新开「关于」页签:插件页现在 8 个页签已经够多,为一个版本号再拆一页反而更散。

### 工程

- `test/ui.test.mjs` 新增一条断言锁住这个顺序(`toolsCard` → 「为什么默认只放这几个」→ `aboutCard`),并且断言「关于」不能紧跟在工具卡后面。以后谁改回去,测试先红。
- 测试数 573 → 574。

---

## 0.4.0 · 2026-10-25

一句话:**装完之后能自己查新版了,仓库也有了一个能点的入口。**

### 新增

- **设置 → 大肥鱼 → 插件 → 「关于」卡**:显示当前版本,一个「检查更新」按钮。
  - 只在**点按钮那一下**出网,插件运行期从不自动查、也不会自动下载或安装(`lib/update.js` 头注释写明这条边界)。
  - 六个源并发:GitHub API 的 `releases/latest` 与 `tags?per_page=1`,外加 `raw.githubusercontent.com` / `ghfast.top` / `ghproxy.net` / `cdn.jsdelivr.net` 四条镜像取 `main` 的 `package.json`。国内哪条通算哪条,多个成功取版本号最大的(镜像有缓存会滞后)。整批共用一个超时预算(默认 6 秒,界面按 8 秒请求)。
  - 六条全失败时说的是人话:「连不上 GitHub(等了 N 秒)。国内时通时不通,过一会儿再试,或者开一下网络工具。」不抛异常、不卡界面。
  - **读不到自己装的版本号时不谎报**:`known: false`,既不显示「有新版本」也不显示「已最新」,只把查到的最新版告诉你并说明判断不了。
- **同一张卡里的 GitHub 入口**:GitHub 商标小图(16×16 内联 SVG 官方 mark,不引外链、不加载图片)+「GitHub」文字,点开新标签到仓库。旁边一句「觉得有用的话,点个 Star 就是最实在的支持。」

### 工程

- 新增 `lib/update.js`(155 行)与 `lib/observe.js` 的 `/update` 路由。**路由里不接受客户端传 URL**——地址全部硬编码在 `update.js`,否则就是个 SSRF 口子。
- `/state` 的 `meta` 多带一个 `version`,界面从这儿读当前版本。
- 新增 `test/update.test.mjs`(12 条,用可注入的假 fetch,不发真网络)+ `test/p7.test.mjs` 1 条 + `test/ui.test.mjs` 3 条(守着「只在点击时查」「图标内联」「读不到不谎报」)。测试数 557 → 573。

---

## 0.3.0 · 2026-10-25

一句话:**把「立场」摆正位置,把仓库补成能给别人看的样子。**

### 修正

- **撤回「能力」这个维度。** `lib/capability.js` 整个删掉(78 行 + 78 行测试)。原因:原来的判据量到的是**说话风格**,不是能力——同一件事用不同语气说,得分就变了,那不是能力。这个维度留着会让「进阶」变成一个可以被话术骗过的数字。0.2.0 里它还在,属于发早了,这一版收回。
- **立场上限从「4 条进身份」改成「内部 12 条、进身份 8 条」**(`STANCE_MAX` / `STANCE_PROMPT_MAX`)。原来 4 条太少:一个走过四个阶段的存在,不会只有四句「我不做」。
- **同一件事说过不止一次、或因此栽过一次,才算一条立场**;两条打架时后来的算数,判不出先后的两条都不留。

### 界面

- **原理图重画**(`media/principle-loop.svg`,画布 1400×980 → **1400×1160**):立场不再是一行小字,而是底部一块独立区块——浅绿底、左侧墨绿竖条、24px 标题「走完四个阶段之后长出来的:立场清单」,三列分别讲「什么算一条」「长什么样」「怎么不弄丢」。阶段 4 到它之间有一根橙色向下箭头,旁注「走完才有」。
- **图与说明文字不再重叠。** 加了一条分隔线(y=1068),三行图注下移到 y=1100/1122/1144;`figcaption` 上边距 6px → 12px。
- 阶段卡里的「门:」统一改成「**瓶颈:**」(「四道门/三道门」全部作废,术语守门有测试拦着)。
- 设置页「立场」那行的提示词删掉后半截——现在只说「要等四个阶段(信任 / 自主 / 主动 / 整合)都走完,立场才开始产生。」,不再解释它「不是第五关」。

### 文档

- **README 补 cookie 安全说明**:cookie 只落在 `~/.dsh-fish/owner.json`(`0600`)、只发给 `api.bilibili.com`、日志自动打码成 `SESSDATA=***`;并明确写出**备份文件里含 cookie**,别随手发人。
- **安装段加了 ffmpeg 前置提示**:刷视频需要本机已装 ffmpeg(`DSH_FISH_FFMPEG` 可指定路径),没装会提示并降级为只看文字。之前只在功能段提了一句「用本地 ffmpeg」,没告诉人要自己先装——这是唯一会卡住新用户的地方。
- README 接上**三张真机截图**(状态 / 成长 / 上网)。
- 新增 **`AGENTS.md`**:只写改代码的硬约束(零依赖、不加人格设置界面、四条黑名单、文案守门词、事件流只能追加、必须跑全套测试)。
- 新增本文件。
- 测试数 540 → 557。

### 工程

- 新增 **GitHub Actions**(`.github/workflows/test.yml`):Linux 上 Node 20 / 22 / 24 三个版本各跑一遍全套测试,另有一个 Windows + Node 22 的作业(路径归一化、chmod 语义、ffmpeg 探测在这边和 Linux 不一样)。push 与 PR 都跑。首次推送需要在 GitHub 上给 token/push 开 `workflow` 权限,否则会被拒。
- **测试脚本改成 `node --test`(不带参数)。** 原来写的 `node --test "test/*.test.mjs"` 只在 Node 22+ 能用——Node 20 不展开引号里的通配符,直接报 `Could not find '.../test/*.test.mjs'`,CI 的 Node 20 作业就是被这条拖红的。`node --test test/`(目录形式)在 Node 26 + Windows 下又报 `MODULE_NOT_FOUND`,也不能用。不带参数是三种 Node 版本、三个平台都认的唯一写法。
- CI 暴露并修掉的两个跨平台 bug:`lib/capture.js` 的日记返回值原来把绝对路径(`diary/xxx.md`)带回给她,Windows 上分隔符是 `\` 所以测试侥幸通过、Linux 上直接命中隐私断言——改为只回日期键;`lib/say.js` 的 `waitTurnEnd` 在没有事件总线时把唯一的定时器 `unref` 了,Node 20/22 上事件循环提前收尾、Promise 永远等不到 resolve(含 `test/notify.test.mjs` 用 `Z:/` 造坏路径、在 Linux 上变成相对路径的跨平台问题)。
- `actions/checkout` / `actions/setup-node` 从 v4 升到 **v5**(v4 会触发 Node 20 弃用告警)。
- 删掉遗留的 `main-old` 分支(已被 `main` 整条取代,删前把它的顶端打成 `archive-main-old` 标签留着,需要时还能翻回来)。
- `package.json` 0.2.0 → 0.3.0,`files` 补进 `AGENTS.md` 与 `CHANGELOG.md`;`lib/index.js` 的 `VERSION` 从 0.1.0 一并升到 0.3.0(这个号写进启动日志,排查时第一件事就是看它)。

---

## 0.2.0 · 2026-10-25

一句话:**走完那四个阶段之后不再加关,往后长的是立场。**

- 新增 `lib/stance.js`:立场清单。它**不是第五关**——四个阶段走完才开始产生,靠「同一件事说过不止一次」「因此栽过一次」这类证据攒出来,每条后面跟一句代价,不跟次数。这一版上限是 4 条。
- 同时新增了 `lib/capability.js`(「横向能力」维度)。**这个维度是错的,0.3.0 已整个撤回**——它的判据量到的是说话风格,不是能力。
- 原理图与设置页加入立场说明。
- README 重写:先讲她怎么长,再讲怎么装(之前是反过来的)。
- README 顶部加示例图 `media/fish.jpg`。
- **改正上游归因**:本项目不含任何 `video-watch` 代码派生,之前 README 里那句归属是错的。

---

## 0.1.0 · 2026-10-25

第一个公开版本:**她能记住、会改口、会拒绝,也会忘。**

- **事件流是唯一真相源**:`~/.dsh-fish/events.jsonl`,只追加不改写;`state.json` 是派生品,`rebuild()` 随时可重算。删掉 `state.json` 能算出完全一样的结果,这条有测试守着。
- **人格靠事件长,不靠配置**:提示词里没有一句人格设定,也没有任何性格滑条 / 好感度数字 / 亲密度进度条。
- **四个阶段与三道瓶颈**:信任 → 自主 → 主动 → 整合,按天推进但每道瓶颈要她自己突破。
- **四条黑名单是代码级硬禁**,不是提示词里的劝告:不撒娇卖萌(写成「还不会」)、没有关系数值、不能读自己的设定与数值、不能调工具改自己。
- **她会拒绝**,也会为自己的拒绝反省;拒绝之后走哪条反馈链取决于你的反应。
- **记忆有重量**:深刻的记住,普通的折叠成一句「记不清原话了」,但不删除——原文还在事件流里,她能用 `her_recall` 翻回去。疤那类永不折叠。
- **她会自己刷 B 站**:`lib/bili.js` 只用 Node 内置 `fetch` / `crypto` / `zlib` 打 `api.bilibili.com`(自己实现 wbi 签名),串行限流 800ms、遇 -352/-412/-509 熔断 30 分钟;能看首页 / 排行 / 搜索 / 详情 / 评论 / 弹幕,能抽帧看画面,能在评论区说话与回话。
- **设置页**(设置 → 大肥鱼):状态 / 成长 / 住所 / 上网 / 记忆 / 插件 / 原理 / 数据 八个 tab,全部是环境参数。
- **观测台不直连浏览器**:`/dsh-fish` 被 App 的登录栅栏守着,浏览器敲只会 401。
- 零运行时依赖,零构建步骤,110 个文件、22712 行。