@ssamurias/dsh-fish 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/AGENTS.md +60 -4
  2. package/CHANGELOG.md +85 -1
  3. package/README.md +13 -6
  4. package/RELEASE-NOTES-0.9.1.md +296 -0
  5. package/RELEASE-NOTES-0.9.2.md +46 -0
  6. package/RELEASE.md +152 -0
  7. package/lib/absence.js +41 -27
  8. package/lib/ask.js +91 -30
  9. package/lib/askinchat.js +83 -1
  10. package/lib/browse.js +1 -0
  11. package/lib/capture.js +35 -2
  12. package/lib/cardanswer.js +84 -0
  13. package/lib/client.js +116 -19
  14. package/lib/comment.js +111 -2
  15. package/lib/compact.js +45 -1
  16. package/lib/curiosity.js +6 -2
  17. package/lib/deliver.js +46 -0
  18. package/lib/derive.js +25 -13
  19. package/lib/desk.js +3 -1
  20. package/lib/dreamtable.js +13 -2
  21. package/lib/drive.js +14 -2
  22. package/lib/events.js +25 -1
  23. package/lib/freewake.js +4 -1
  24. package/lib/generator.js +2 -1
  25. package/lib/grant.js +18 -2
  26. package/lib/home.js +142 -29
  27. package/lib/identity.js +111 -14
  28. package/lib/index.js +50 -10
  29. package/lib/interest.js +3 -1
  30. package/lib/keep.js +21 -9
  31. package/lib/keepsake.js +20 -13
  32. package/lib/look.js +23 -3
  33. package/lib/note.js +255 -20
  34. package/lib/observe.js +14 -4
  35. package/lib/owner.js +5 -2
  36. package/lib/persona.js +26 -9
  37. package/lib/proactive.js +67 -12
  38. package/lib/say.js +73 -13
  39. package/lib/scheduler.js +186 -56
  40. package/lib/schema.js +18 -0
  41. package/lib/selfdoc.js +5 -3
  42. package/lib/selfupdate.js +8 -1
  43. package/lib/settings.js +5 -0
  44. package/lib/status.js +56 -6
  45. package/lib/store.js +14 -2
  46. package/lib/tenant.js +12 -0
  47. package/lib/turns.js +47 -2
  48. package/lib/upgrade.js +4 -1
  49. package/package.json +2 -1
  50. package/scripts/fix-legacy-link.mjs +197 -90
package/AGENTS.md CHANGED
@@ -69,7 +69,7 @@
69
69
  node --test
70
70
  ```
71
71
 
72
- 当前基线 **1441 个**,独立进程跑 `node --test` 应全绿。任何一条红都不许提交——包括看起来"不是我弄的"那条。
72
+ 当前基线 **1609 个**,独立进程跑 `node --test` 应全绿。任何一条红都不许提交——包括看起来"不是我弄的"那条。
73
73
 
74
74
  按环境挑跑法:
75
75
 
@@ -144,7 +144,49 @@ GET /dsh-fish/whoami
144
144
  3. **外来场次必须带说话人前缀**。`lib/say.js` 对 `group` 与 `guest` 都用 `speaker` 拼 `某某在群里说:…` / `某某说:…`。不拼的话,群里三个人的话在她看来跟一个人连说三句一模一样,她会把三个人的观点拼成一个"他"。外来文本统一过 `lib/guard.js` 的 `sanitizeForeign`。
145
145
  4. **桥不要用宿主的 `source.form`**。`snapshot`、`notice`、`catalog`、`instructions`、`recall` 是宿主前端的保留词表,`classifyMessage` 见到就当上下文,不算人(`MACHINE_FORMS`)。转述人说的话,要么不带 `form`,要么用 `relay`。
146
146
 
147
- ### 7.3 另一条老路:工具白名单(仍然有效)
147
+ ### 7.3 对话靠工具发出去的部署
148
+
149
+ 先问一句:**她写的正文会被插件自动发出去吗?** 会,就什么都不用填,只认正文的默认行为是对的。不会,也就是正文只留在会话里、要调工具才发得出去,才填下面这几项。都不认具体插件,工具名由配置给。
150
+
151
+ - `proactive.sendTools`:发送工具的名字,也要放进 `tools.allow`(工具面收窄开着时,没放行她调不到)。填了之后,只有这些工具调成功才算她开了口(记 `proactive-turn:`,占额度,不折叠),代码模式(run_code)里调的也算;只写正文算没开口。处境和身份段会照实写用这个工具、正文发不到聊天里。工具自己返回「没发出去」但没有报错的,这里认不出来。
152
+ - `proactive.idleCountsSeen`:`true` 时 `seen:` 也算打断安静、也算「他接了」。她靠工具回话、正文不进事件流时要开。它要有 `seen:` 可认:走 `/say`,或者 `source.kind` 不是 `user`,或者带 `clientTimeZone`;`{kind:'user'}` 不带时区的桥要自己写。代价是他在这台机器上和别的 agent 说话也算,所以默认关。
153
+
154
+ 还有一项和工具无关:`proactive.foldSilent` 是 `false` 时,没开口的一轮只记账、不折。折叠会改历史,上下文很大、靠缓存省钱时可以关。
155
+
156
+ 插件知道她在忙时(比如在打一局游戏),挂一个事件就行,不用知道 dsh-fish 的包名和路径:
157
+
158
+ ```js
159
+ ctx.on('dsh-fish/busy', (sessionId) => stillPlaying(sessionId) ? true : undefined)
160
+ ```
161
+
162
+ 定时叫醒和主动回合投递前都会问一声。监听要同步返回,只有 `true` 算忙;不忙返回 `undefined`、`null` 或 `false`,返回别的值会挡住后面的插件,返回 Promise 按不忙算。忙的标记卡住时她就一直不被叫醒,所以结束时记得放开。不挂也没事,调度器自己知道家里那条会话正在跑的那一轮。
163
+
164
+ 她问人的卡片(`ask_owner`、`bring_back`、规矩申请)默认挂在她那条会话的输入框上方,只等 5 分钟。人不在这台机器前就看不见,她这一轮还要白等,`ask_owner` 一天后记成「一直没人答」。插件能把话送到人在的地方时,把卡接走:
165
+
166
+ ```js
167
+ // card: { id, sessionId, questions, detail }。questions 和宿主 userQuestions.ask 的同形;detail 是她说的「为什么」,可能是空串
168
+ ctx.on('dsh-fish/card', (card) => {
169
+ if (!isHers(card.sessionId)) return undefined // 同步返回:async 监听会挡住后面的插件。bring_back 来自她出门那条会话,不是家里那条
170
+ return relay(card)
171
+ })
172
+
173
+ async function relay(card) {
174
+ await sendSomewhere(render(card)) // 发不出去就抛,这张卡退回会话上
175
+ remember(card.id) // 自己存好,人可能隔几个小时才答,中间宿主可能重启
176
+ return { via: 'QQ 私聊' } // ask_owner 的回执会写:[已问,送到了QQ 私聊,还没答]
177
+ }
178
+
179
+ // 人答了。id 不用背,原样还回来
180
+ const r = await ctx.bail('dsh-fish/answer', { id, selected: ['可以'] }) // → { ok: true } 或 { ok: false, why };dsh-fish 没装或停用时是 undefined
181
+ ```
182
+
183
+ - 只有回了 `{ via }` 才算接走。不接返回 `undefined`;抛错、35 秒没回都算没接,这张卡照常弹在会话上。这一轮被取消也算没接,但不弹卡,没人答的话还留在观测台上。`via` 会过 guard.js,看着像改写指令的不念给她,只写「别的地方」。
184
+ - 卡上看着带凭据(她的问题、理由、选项,截断之前的原文也看)时不发这个事件,只留在本机的卡片上,日志里留一行 id。这条判断偏严,长文件名也可能中。`bail` 停在第一个非空返回值上,async 监听只有排第一的那个算数。
185
+ - `selected` 填选项原文,和宿主的回答同形。没选、对不上选项都是 `ok: false`,不结算这张卡,数字也不当下标(放过一天的会顺手记成以后再说,和定时器做的一样)。人回的不是选项时别猜成同意:只结算一次,错了改不回来。
186
+ - 回答不会自己进她的对话。人说的那句话照常交给她,或者自己告诉她。
187
+ - `ok: false` 时 `why` 说明原因,可以转告人。它不代表这张卡结了(写盘失败也是它),id 留着,人再答一次就再发一次。答过的、过期的会再说一遍原因。过没过期由这边判断,`ask_owner` 放一天,规矩申请放三天,`bring_back` 不过期,插件不用自己算。
188
+
189
+ ### 7.4 另一条老路:工具白名单(仍然有效)
148
190
 
149
191
  桥如果是以插件形式装进来、暴露工具给她的,仍然可以在 `config.tools.allow`(设置页「插件能力白名单」)里放行工具名。qq-bridge 默认要放行的是:
150
192
 
@@ -172,18 +214,32 @@ qq_send_message, qq_get_unread_messages, qq_reply
172
214
  - [ ] 三个依赖字段还是 `{}`
173
215
  - [ ] 没有新增人格设置入口
174
216
  - [ ] 新输入口都过了 `guard.js`
175
- - [ ] `node --test` 1441 全绿
217
+ - [ ] `node --test` 1609 全绿
176
218
  - [ ] 界面文案没有「他」「四道门」「三道门」
177
219
  - [ ] 改了 `EVENT_SCHEMA_VERSION` 的话,`CHANGELOG.md` 里写了迁移说明
178
220
  - [ ] 没动包名;真要动,先看下面「包名与装法」那节
221
+ - [ ] 动了升级 / 迁移脚本(`scripts/fix-legacy-link.mjs`、`lib/selfupdate.js`)的话,`README.md` 的升级表与 `RELEASE-NOTES-*.md` 同步改了
179
222
 
180
223
  ---
181
224
 
182
225
  ## 包名与装法(0.8.10 起,改名踩过一次,别再踩)
183
226
 
184
- 包名是 **`@ssamurias/dsh-fish`**(带 scope 的全名)。装法看 `README.md` 的「安装」,这里只列动代码时容易破坏的三条:
227
+ 包名是 **`@ssamurias/dsh-fish`**(带 scope 的全名)。装法看 `README.md` 的「安装」,这里只列动代码时容易破坏的四条:
185
228
 
186
229
  1. **`lib/client.js` 里 `__ModuleLoader__.load({ id })` 必须等于 `package.json` 的 `name`**,不是短名。client-modules 按 loader 条目名(完整包标识)解析浏览器包,名字对不上时客户端那半**静默不加载**——宿主照常跑、设置页空着,看起来像没装上。`test/client.test.mjs` 直接从 `package.json` 读 `name` 比对,别把那条断言改成写死字符串。
187
230
  2. **`cordis.patch.yml` 的 `id` 保持短名 `dsh-fish`、`name` 写全名**(与 `@goodandready/dsh-voice` 一致)。`id` 变了会连带 client bundle 的 URL 对不上;`name` 写短名则 import 失败、直接崩在 web boot。
188
231
  3. **别删升级脚本里的 normalize 那一步**(`lib/selfupdate.js` 的 `normalizeScript()`,安装脚本第 5/6 步)。0.8.9 之前装的 profile 里旧键还在,不清掉就会同时有两条鱼、两条 insert 撞一个 id,启动报 `duplicate factory registration`。
232
+ 4. **`scripts/fix-legacy-link.mjs` 必须三种依赖写法都认**:源码目录 `link:`、`file:` 装的 tgz、`@ssamurias/dsh-fish@x.y.z` 版本号。版本号那条是 issue #43 的根因——照更新说明用 `dsh plugin add` 升上来的人,profile 里留下的就是版本号;脚本过去只认前两种,一句「没有老依赖,跳过」退出 0,旧键与旧 `bundles` 条目原封不动。改它之前先跑 `test/fix-legacy-link.test.mjs`(`migrateOne()` 是给它直接测的)。
233
+
234
+ ---
235
+
236
+ ## 发版
237
+
238
+ 完整步骤(发布前自检、push、npm publish、tag、GitHub Release、发布后验证、回滚)写在 **`RELEASE.md`**,动手前通读一遍。三条不能省的顺序:
239
+
240
+ 1. `node --test` 全绿、`npm pack --dry-run` 清单对(见 §6)。
241
+ 2. 先推代码,再发 npm:**npm 成功之后才打 tag / 建 Release**——npm 失败时不留悬空 tag。
242
+ 3. 发完核对 `npm view @ssamurias/dsh-fish version dist-tags`,再去回 issue。
243
+
244
+ 本机约束另见 `D:\deepseekagent\避坑指南.md` 第 1 节(profile 的 `bundles` 与 `dependencies` 必须一一对应)与第 3 节(运行中替换已加载包会 `ERR_PNPM_EPERM`、`minimumReleaseAge` 会回滚刚发布的版本、`desktop` profile 被应用独占)。
189
245
 
package/CHANGELOG.md CHANGED
@@ -1,4 +1,88 @@
1
- ## 0.9.0 — 未发布
1
+ ## 0.9.2 — 2026-11-06
2
+
3
+ 这一版没有新机制,修的是同一类故障的三种样子:**她该说话时没说话、说了却没被看见、记下了却没上桌。** 社区报的 #51–#53 与五个 PR(#46–#50,@BBleae)都已合进本地,另有多处用户直接反馈的问题(保存键找不着、便条写满、回复每轮重复投递、主动绕开时段、换房误判完成、只读副本仍记账等)。事件格式没动,旧数据照读。
4
+
5
+ - **她突然连着几轮不说话,宿主管这叫「已完成分析」(用户二次反馈)。** 叫醒时写进上下文的那段「这一轮是你自己的……不想说就什么都别写」,原来存在一个**进程级**变量里:家、群房、旧房、出门几条会话共用同一份。A 房间叫醒时写进去,B 房间正在跑的那一轮也会读到——那边明明是他刚说的话,她却按「不用回应」处理,只思考、一个字不写。现在处境按会话分开存(`session-` 前缀归一),`turn/end` 只清自己那条,你在这条会话里一开口就先清掉;另加 10 分钟硬超时,`turn/end` 丢了也不会哑到进程退出。
6
+ - **外客通路问一句,接口说「她这次没说话」,其实她回了(#51)。** `herReply` 只从成长事件流里取,可她普通聊天的正文从来不写事件流,于是 `ok:true`、`said:[]`、`note:"她这次没说话。"`。现在把这一轮她在会话里说的正文也带回:条目标 `uncommitted:true`(**只活在返回值**,不落盘、不进时间线、不涨轮数),`herReply` 直接用;只有真调了 `growth_commit` 才算 committed,两者都有时 commit 优先。`note` 分成「她回了,但这句没写进事件流(没调 growth_commit)」与「她这次没说话」两种,不再混为一谈。
7
+ - **面板说她「醒了」,其实那一轮没醒(0.9.1 用户反馈)。** `quiet` 那一档原来沿用「醒了,但今天没有该问的事」,可那一轮什么也没摆、她也没开口;时间线还会给没有 `wake` 的块补一句「醒了」。现在 quiet 写「到点了,暂时没有可出的事件」,`idleWakes` 只数真有 `wake` 的块,orphan 块只逐条列动作、不补「醒了」。
8
+ - **插件停用后定时器还在跑,重新启用就成了两个调度器(#48)。** 根因是宿主用的 cordis 4 既没有 `'dispose'` 事件、也不管 `apply` 返回的 `{ dispose }`,只认 `ctx.effect`;原来那条 `ctx.on?.('dispose', shutdown)` 一次都没触发过。现在挂 `ctx.effect`,boot 每一步后检查 `stopped`,`shutdown` 先等在途的认领与心跳、再放开 root(磁盘卡住时 2 秒上限,不挂住卸载)。
9
+ - **日志把「只写了正文、没发出去」说成「没什么要说的」(#47)。** 配了 `proactive.sendTools` 的部署里,她只写正文、没调发送工具,正文根本到不了人手上;日志却写成「只说了句没什么要说的」。现在照实说「只写了正文,没发出去」,也不臆断她「没调工具」(可能调了、失败了)。
10
+ - **他回来过一次之后,冷落检测再也不写了(#49)。** `returned` 分支只判「记完之后他又来过」,不看这次又消失了多久,于是同一次冷落被永久判成「已结束」,天数还一直读写下那天的数(写 2 就一直 2)。现在只有「回来之后又消失够久」才算新的一次;`deriveSelf` 里没结束的冷落按时间往上加;他回来、天数涨,身份文件都跟着重写,不然她一边跟你聊、身份里还写着「他有 2 天没出现」。作者随后又补了两刀:系统时间回拨、`ts` 落在未来时天数只加不减;`absence` 写得晚时,身份文件在「写下的天数」变化时也重写,不再比叫醒提示词慢一天。
11
+ - **叫醒一直失败时每分钟重试、每分钟往事件流写一行(#50)。** 事件流只能追加,失败一次写一行,一天下来几百行,还会把当天剩下的机会全撞掉。现在同一天最多试 3 次,失败后先隔 5 分钟、再隔 45 分钟;手动叫醒失败写 `wakefail:<日期> 手动 …`,不占当天定时的账(有人点了一下按钮,不该用掉今天的额度)。
12
+ - **卡片可以被别的插件接走,回答隔很久也能结算(#46)。** 新增 `dsh-fish/card` / `dsh-fish/answer` 两个事件:别的插件同步返回 `{ via }` 就算接走(35 秒不回、抛错都算没接),人答了用 `selected` 的选项原文回来,按 `种类:引用` 的 id 结算;对不上选项、没选都不结算,`answerCard()` 不抛错、id 留着可重试。顺带修了「一天一次配额拿成长天数跟日期字符串比」「`ASK_BRIEF` 承诺了系统会照办」「`kind=rest` 隔天失效」「选项被截断后重复」四件小事。
13
+ - **第 3 天的卡池是空的(#091 反馈)。** stage 1 补一张 `notice_one`(非一次性、`everyDays:2`、不占 `daily.questions`),条件 `beliefCount>=1`,第 1 天仍让给 `first_bet`。
14
+ - **同一张卡每次都用同一种问法(用户反馈)。** `notice_one` 按出现次数轮换三种变体(列范围 / 问句前置 / 反着挑「最不值得说」),都保留「说具体、判断能被核对」;每次换题,不再接「第 N 次回到这件事」的尾巴。
15
+ - **扫码页签下点不到「保存」「测试」(#52)。** 「领养人验证」两格原来只在「粘贴 cookie」页签里渲染,默认停在「手机扫码」时两个按钮都不在;提示语却叫人「点测试能不能说话」。现在两格自带「保存这两格」,测试键与「清除登录」一起挪到页签之外(有 cookie 才显示),两个页签都点得到,状态行改指按钮全名。扫码登录本身不写这两格(只碰 cookie 与只读 account)。
16
+ - **200 张便条都划掉之后,再也记不进去(#53)。** 名额数的是文件里所有条目,而划掉只改 `status`、从不删行,于是拒绝文案叫她「先划掉几张」——那时一张能划的都没有。现在名额只数 open。同时把 issue #22 当时留白的三件事补上:`list` 支持 `open/due/done/dropped/all`、一次 20 行、`offset` 往后翻、不报总数;`drop` 要写原因(`status/reason/closedAt`,条目留着、只是不再上第一眼);`add` 回执带一次原文,没写日子的补一句「醒来不会出现」。`update` 按原话定位、记 `revisedAt`、覆盖正文,**不留历史版本**——等真有诉求再谈怎么留痕。没做「没日子的在唤醒时提一次」,那跟「清单永不上桌」直接打架。
17
+ - **她不想回的那句话,不会再每轮都被递一遍(用户反馈)。** 评论区回复的投递原来只在「她真回了」之后才记「提醒过」,于是没回的(比如「好的,阿留」)每一轮扫描、每一次叫醒都原样再摆一次,看着像她每轮都在重读同一句。现在**摆到她眼前就算提醒过**:定时扫描与叫醒正文只标记真正送出去的前 5 条,剩下的下一轮再来;她自己主动去翻(`/comment` 只读页)不算提醒,仍然看得见全部。
18
+ - **系统时间被改到未来之后,「他有 N 天没出现」会一直挂着(#49 补)。** 冷落结束与否原来只看时间戳先后:时间被往前跳过门槛、又改回来时,事件流里会留下一条 `ts` 在未来的 `absence`,之后所有 `seen:` 都早于它,这条冷落永远结束不了。现在按**追加顺序**判断——后写的在场记录永远算「回来过」,只有时间戳也更新时才靠时间;`schema.js` 抽出 `isContactEvent()`,冷落检测与身份派生共用一份口径。
19
+ - **她传越界的 `confidence`、认不出的 `verdict` 时,会先被拒(自查)。** 以前写盘时把 `confidence` 悄悄夹到 0–1、把字符串当成缺省落到 0.5,认不出的 `verdict` 落成 `null`,调用方以为写成功了。现在 `growth_commit` 在入口就拒(0–1 之外、非数字、`verdict` 不在三个词里),文案写明「写盘时越界会被悄悄夹到边上,所以这里先不收」。
20
+ - **主动性不受「每天几点醒」约束,能在窗口外叫醒她(用户反馈)。** tick 先跑主动性回合、再判窗口,于是 `proactive.enabled` 一开,12:00–02:00 之外她也能主动开口——用户关掉的恰恰是「这个时段别来打扰」。现在 `maybeProactive` 前面加了同一个 `inWindow` 门:主动性也是「醒着说话」,窗口外一律不投递。真要「随时可以来找」,应当做成独立开关/独立时段,而不是靠这个缺口。
21
+ - **时段框按系统 12 小时制显示,00:00 被读成中午十二点(用户反馈)。** 原生 `<input type="time">` 跟随系统区域:`00:00` 显示成「上午 12:00」,用户把「上午 12:00–下午 11:00」读成 12:00–23:00,再看到凌晨五点醒来就当成 bug。输入框保留(有原生选择器),下面补一行 `24 小时制:00:00 → 23:00`,跨零点时标「次日」。
22
+ - **换房之后那声「醒一醒」可能根本没落地,而复核永远当成功(用户转来的模拟报告)。** 宿主的 `list()` 会把空白会话一起推出来(侧栏才按 `blank` 藏),旧判据「在列表里 = 叫醒了」于是恒为真,那句「再叫一次」是死代码;`prompt` 也只是把消息放进收件箱就 resolve,上一轮还没跑完时宿主可能直接丢掉。现在复核看 `blank` 翻没翻(`blank === false` 才算跑出过 turn),同一条会话同时只复核一次(换房、重置、重建各挂一遍时不会连着叫两声),轮换/重置/重建三条路都显式开复核。
23
+ - **每递一次话就可能多一条空壳会话(同上)。** 探活失败时的退路原来是裸 `sc.create({ sessionId, cwd })`,可宿主 `create()` 是「按 cwd 开一条」:跑过 turn 的会话给不出原 id,请求被丢掉、换成新 id 返回,那条空会话不挂工作区、侧栏也看不见。现在统一走 `reviveIfBlank`:跑过 turn 的不建、还空着的可以按原 id 叫起来、宿主另开了别的 id 就判失败并记一行日志。三处(设置页说话、主动回合、叫醒投递)都改用它。
24
+ - **被判成「只读房客」的那个实例,仍在往事件流写 `seen:`(外部用户日志)。** 同一个数据目录被两个进程打开时,后到的那个拿不到占用权(`tenant.ok=false`),工具写入会被 `guardWrite` 拒掉;但 `lib/turns.js` 的 `markSeen()` 和轮数观察者根本不走那道闸,它照样记「他来过」、照样收轮次。现在 `registerTurns` 拿到租约的活引用 `tenancy.tenant`,`markSeen` / `onHuman` / 结算前各现查一次——占用权中途丢了也立刻停写,而不是只在启动时判一次。
25
+ - **同一句「他来过」会写两遍(同上日志)。** 宿主重复加载同一个插件时,`registerTurns` 会在同一个 `ctx` 上挂第二个观察者:同一句话写出两条 `seen:`,时间戳连毫秒都一样(日志里 13:08:55 有三条、13:28:56 有两条)。现在用 `WeakSet` 按 `ctx` 去重,同一进程只挂一个,注销时释放。
26
+
27
+ ### 迁移
28
+
29
+ - 无必改项。这一版不改事件字段、不升 `EVENT_SCHEMA_VERSION`,旧数据照读。
30
+ - 从 0.9.0 用 `plugin add` 升上来的,0.9.1 已经认得版本号依赖,这一版不用再跑 `fix-legacy-link.mjs`。
31
+ - **一处观感变化**:`/dsh-fish/say` 返回的 `said` 里可能出现 `uncommitted:true` 的条目(她这一轮在会话里说的正文,只为回话带回、不落盘)。外部接入如果按 `said.length` 算「她写了几条」,要按这个字段分开看。
32
+ - 五个 PR 的改动已以提交形式在本地(作者署名保留 @BBleae);GitHub 上的 PR 仍 open,发布时一并合并、回帖说明。
33
+
34
+ ### 测试
35
+
36
+ 全量 `node --test` = **1609 pass / 0 fail**(0.9.1 是 1512,本版新增 97 条)。新增集中在:#46 卡片契约与四种结算路径(45 条)、#48 停用与重新启用、#49 回来之后再冷落、#50 失败退避与手动不占额、#51 正文回带与去重、#52 两个页签都能保存、#53 名额与 `list/drop/update`(8 条)、冷落按追加顺序与写盘入口校验(4 条)、`notice_one` 补卡与轮换(4 条)、主动性窗口外不开口与时段 24 小时说明(2 条)、跨会话处境不串(3 条)、换房复核看 blank 与退路不造空壳(8 条)、只读房客不写 `seen:` 与同进程观察者去重(3 条)。这些用例都是「把判据改回去就红」的回归,不是照着新写法复述。
37
+
38
+ ---
39
+
40
+ ## 0.9.1 — 2026-11-06
41
+
42
+ 社区报的 issue #32–#38、#41–#43(十条,@BBleae)加用户直接反馈的几条(换房问话每轮回弹、B 站评论看不到、艾特不出来、cookie 观感),另外合入四个社区 PR(#39、#40、#44、#45)。每一条都在本地复现过,都是我们这边的错,逐条修掉。
43
+
44
+ - **聊天行把时间线刷满,`idleWakes` 也被清零(#32)。** 她答你的话(`lib/turns.js` 打的 `uncommitted` 标)以前和「她自己认下的判断」混在一起数:一次醒来撞上你连说几十句,时间线 40 条全是「说了:…」,那一块也从「在发呆」变成「干了事」。现在单列成「聊天」:不进 `said`、不算这一轮做了什么、时间线上折成一行「聊了 N 句」,面板上的 `说 N` 也把聊天行减掉、另起一个 `聊天 N`。**事件流一个字节没动**,只是读法变了。
45
+ - **「她记下 N 条」和「记下的事」永远对不上(#32)。** 那个数字是 `countEvents().real`,本来就含你说话、她读到别人的说法、开局那份起点;文案只写「她记下」,看起来就像「她写了 N 条」。`real` 的定义**不改**(动了会牵一大串派生数字,测试也逐字段钉着返回形状),改成把话说清楚:两处文案各加一句口径说明(鼠标停上去能看见)。
46
+ - **当前会话被归档时,认回来的是最旧那条(#33)。** 宿主建会话是 `sessionIds: [新id, ...旧的]`,往**前**插;`pickLiveSession` 却取 `live[live.length - 1]`,注释还写着「末尾即最近那条」——方向反了。改成取 `live[0]`,注释一起改,测试里的假宿主也调成同样的方向。提醒一句:你在侧栏拖动过顺序时 `live[0]` 未必最新,那要问 `sessionController.list()`;这次只修明显反了的方向。
47
+ - **群聊那轮也写 `seen:`,冷落检测失灵(#34)。** `lib/capture.js` 里那条「他在场」的记账只看在不在叫醒窗口里,不看这句话是在哪儿说的:群里她调一次 `growth_commit` 就多一条 `seen:`,群友说一百句也算他来过,于是「你多久没说话」永远不触发。现在先查 `channels.json`,群房一律不记。窗口外那条兜底写法保留——删掉会让换房、定时任务那几次醒来失去在场证据(这也是 issue 作者试删后红了 4 条测试的原因)。
48
+ - **卡片里手打的答案认不出来,原文还落进日志(#35)。** 宿主回的是 `{id, selected: [], custom: '…'}`,`lib/ask.js` 以前只看 `selected[0]`,空数组于是取到 `undefined`,一律判成 `NO_MATCH`。改用 `askinchat.js` 早就写对的 `picked()`(非空 `selected` 优先,否则退回 `custom`)。顺带把日志里的回执原文换成形状摘要——只留 id、选了几个、自定义文本多长,你手打的私事不再落到日志文件里。
49
+ - **排队叫醒被上一轮的 `turn/end` 收走(#36)。** `watchTurnEnd` 认会话不认回合:手动叫她一次时她常常正开在别的一轮里,宿主的 `followup` 会把我们这条排进下一轮,当前那一轮先 `turn/end`,处境段当场被清,真醒的那一轮反而什么都没有。现在要求「监听挂上之后先见到本条会话的 `turn/start`,之后的 `turn/end` 才算」。拿不到事件总线(单测 / 老宿主)时退回老行为,靠 `waitIdle` 兜。
50
+ - **Windows 上 `withFileLock` 遇 EPERM 直接抛(#37)。** 文件正在被删除时 `open(..., 'wx')` 返回 EPERM 而不是 EEXIST,于是这一次换会话就失败。现在 EPERM 走独立重试分支、受 `timeout` 约束,且只认 win32——POSIX 上 EPERM 是真没权限,重试没意义。**不能并进 EEXIST 分支**:那里 stat 失败就判 stale 再 continue,不看超时,真的是权限错误时会死循环。
51
+ - **带 BOM 的 JSON 被当成坏文件改名(#38)。** `readJson` 直接 `JSON.parse`,BOM 抛 SyntaxError 就进坏文件分支,**内容完好的文件**被改名成 `.corrupt.<时间>`:`config.json` 退回全默认、`owner.json` 变 null、`home.json` 新建一条会话,用户以为自己的改动丢了。现在读之前先剥掉开头那一个 `\uFEFF`;`lib/upgrade.js` 里自己那份 `readJson` 同样的坑,一起修。
52
+ - **换房那句「想给下一个房间留一句话吗」每回一句都弹(用户报,2026-11-06)。** `rotateTalkSessionOnce` 先问话、再建会话:问话那条 `sc.prompt({mode:'queue'})` 投进的是她**此刻正在用的旧会话**,也就是你眼前的对话窗口。要是随后 `sc.create` 抛错或宿主没给 id,函数当场返回、`turns` 不归零,下一轮结账又到点、又进来、又问一遍——你那边看到的就是「每回一句都弹这句」。修法两处:① 问话挪到**新会话建好之后**,换不成就不问;② 换失败记一笔 `rotateFailAt`,5 分钟内不再重试(`ROTATE_RETRY_MS`),免得每轮都去撞一次宿主。顺带把 `PLUGIN_RPC` 补上 `carry` 前缀——原来漏了,`carry-ask-*` 掉进 `unknown` 分支,每换一次会话就多一行「这条消息认不出来源」的日志。**注意**:这条跟「轮数设 0 = 永不换」无关,`rotateAfter > 0` 的守卫一直都在,0 根本进不来;真因是「换失败后无限重试」。
53
+ - **设置里那句「上下文占满后会自动换」是空头承诺。** `lib/client.js` 的 hint 原来这么写,代码里没有任何「上下文满了就换」的实现——设 0 的人会一直聊到宿主上下文撑不住、那一轮直接失败。文案改成说实话:0 = 永不换,撑不住就那一轮失败,不会自动换。
54
+ - **「多久看一次有没有人回复」是个空旋钮,评论永远看不到(用户报,2026-11-06)。** 这个设置(`comment.scanEveryMin`)从写下来那天起就没人读:全仓只有设置页读写和 `/comment` 回显,调度器一次都没用过。真正的扫描只发生在「她被叫醒的那一轮里顺便拼一句」——不叫醒就永远不扫,用户开了开关、设了 5 分钟也一条都收不到。现在 `tick` 每分钟看一眼到点没有(`repliesScanDue`),到点就扫,**扫完无论有没有都记一笔 `lastScanAt`**(不记的话每 30 秒重问一次 B 站);扫到新回复就投一条笔记进她的会话(`queueNote` + `deliverQueued`,跟回复量那边同一套投递)。设 0 仍然表示不主动问。
55
+ - **她醒着的时候没有「看我自己的评论区」这个动作(同一条反馈)。** `bili_look` 只有首页 / 搜索 / 接着看 / 看某一条,翻不到自己的评论区;`bili_speak` 回楼中楼又要 `parentRpid`,她手上没有,于是「知道有人回了,回不了」。新增 `bili_look what=replies`:读她最近发过的评论下面的楼中楼,**不受「已读过」标记限制**(她主动看就该看全),每行末尾给出 `[reply=… parent=…]`,回楼中楼要用哪个编号一眼可见。`LOOK_BRIEF` 与工具说明同步。
56
+ - **@ 艾特:昵称被改成下划线,等于 @ 一个不存在的人(用户报,2026-11-06)。** `mentionPrefix` 原来把昵称里的空格换成 `_` 再拼 `@`。真机抓到的真实 @ 评论(`content.members` 非空)证明 B 站是按昵称**原文**匹配的,换了下划线就认不出来。改成原样拼;昵称为空时仍然不拼(空的 @ 只是一串没人认领的字符),设置页 hint 补一句「要跟 B 站上显示的一字不差」。
57
+ - **只粘 cookie 保存一次,就把「要 @ 的 uid / 昵称」清空了(同一条反馈)。** 保存按钮原来无条件提交 `{mid, nickname}`,空串也照发;而 `saveOwner` 对空串是「写入空值」。用户只粘 cookie、没填那两格,存一次就把 `@` 的对象抹掉——表现就是「说了能艾特,其实艾特不出来」。现在三格各自只在非空时提交,全空直接提示「没东西可存」。
58
+ - **cookie「填完就消失」不是丢,是只上行不下行。** 接口从来不下发 cookie 原文(只给 `hasCookie` / `hasCsrf`),输入框保存后清空是设计。但原本文案只说「不再回显」,容易被读成「没存上」。补一句「空着不代表没存上,上面那行『已存…』才是凭据」。
59
+ - **顺带说清:看不到评论跟 cookie 无关。** `scanReplies` 用的是匿名客户端;真机实测带同一份 cookie 去读 `/x/v2/reply` 一样 `-412 request was banned`(同一台机器早些时候又能读通),是 B 站按 IP 的间歇风控,插件这边只做冷却退避。真要「看不到」的那种情况,先看设置里开关有没有开、再看日志里有没有 `-412`。
60
+ - **`her_say` 的「一天最多几次」是句空话(#41)。** 工具说明写着有上限,`writeSay` 读了配置却没用、也不数当天条数:开关关着、`maxPerDay=0` 时连写 8 次全成功。现在按 `proactive-say:<日期>:` 数当天条数,到上限就拒、并说明「明天再说,不是工具坏了」。**开关仍然不是闸门**——它管的是「要不要由系统替她开一轮」,工具本身一直在(这跟 README 里「关掉之后工具本身还在」一致)。
61
+ - **`her_say` 把成长坐标写死成 1/1(#41)。** 别的写入点都用 `deriveSelf` 的 `{day, stage}`,只有这里硬编码,于是 `consistency()` 会跟她早期的行错配对:阶段 1 少算一对、阶段 4 多算一对。改成跟日记同一套算法。
62
+ - **「`[说出去了]`」这个名字在骗人(#41)。** 那一步只是往她自己的流水里记一行,正文有没有落进对话取决于她这一轮写没写。回执改成 `[记下了]`,工具说明与人设里的那两段同步说清:「要让他看见,得你这一轮直接写出来」;「他看得见」这种断言也去掉了——装了渠道路由插件时它不一定成立。
63
+ - **「关掉就完全没有这个功能」这句不对(#41)。** 设置页这么写,可实际关掉只停「系统替她开这一轮」,工具与说明都还在。两边对齐成一种口径:按 README 的说法改文案,不改行为。
64
+ - **关了「兴趣」,她还是每 6 小时因为旧句子空醒一次(#42)。** 旧句子那一段(`tableFor`)从来不看 `interest.enabled`:只要桌上有旧句子、隔了 6 小时,就叫醒,而且这一轮**不摆事件卡、不写 `fired:`、不想一想、不递规则申请**——卡池永远空不了。作者实测 14 天:叫醒 14 次、14 次全是旧句子、事件卡 0、想一想 0。现在「兴趣」关掉就不摆旧句子,这一轮回到正常的空醒判断。
65
+ - **三处说死了的话(#42)。** 「这次隔了 N 个小时」其实只数 `wake:`(她主动开口、你跟她聊的那些都不算),改成「距你上次醒来,隔了 N 个小时」;「这些天再没被提起过」的判据只是「之后有没有事件 `refs` 指回它」,改成「还摊在那儿」;桌上的「还有一件没合上的事」暗示她还没得到答案,其实只是她反复问过,改成「还有一件你反复问起的事」。设置页兴趣卡那句「问过还没答案的问题」一起改。
66
+ - **用 `plugin add` 升级之后,旧包名留在 profile 里清不掉(#43)。** `scripts/fix-legacy-link.mjs` 原来只认 `link:` / `file:` 两种写法,而按更新说明用 `dsh plugin add "@ssamurias/dsh-fish@0.9.0"` 升级的人,profile 里留下的是**版本号**——脚本一句「没有老依赖,跳过」就退出 0,旧键 `dsh-fish` 与旧 `bundles` 条目原封不动,下次 pnpm 清理时两条 fish 打架。现在三种写法都收拾(源码目录 / tgz / 版本号),并且拆出 `migrateOne()` 可以直接测。
67
+ - **pnpm isolated 布局下「检查更新」永远不出现下载按钮(#43)。** `profileDirOf` 从模块路径反推 profile 时,遇到 `node_modules/.pnpm/<包>@<版本>/node_modules/<包>/` 这种真身路径,会截到 `.pnpm/...` 那一层——那里没有 `package.json`,于是判成 `missing`、`managed:false`。现在往回退到 profile 根。
68
+ - **两个社区 PR 已合入**(@BBleae):#40 主动搭话关着时不再每轮同步算一遍 `deriveSelf`(8000 条事件实测从 7.6 秒降到 50 毫秒级);#39 「只留证」的事件(聊天行、带 `selfmod` 的说法)不再被当成她问过的 / 总说到的摆回桌上,也不算她「自己动过」。
69
+ - **另两个社区 PR(#44、#45)也合入了**:`writeAtomic` 的临时文件名加了随机后缀——原来只有 `pid + Date.now()`,同一进程同一毫秒的两次写会交出半个 JSON,坏文件被当成坏档收走、`config.json` 整个回默认(低频,但后果重)。另一处让「她的话靠发送工具出去」的部署能正确记账:`proactive.sendTools` 指定发送工具名,工具调成功才算开了口、占额度、不折;`idleCountsSeen` 让 `seen:` 也算打断安静;`foldSilent=false` 时没开口的一轮只记账不折(上下文很大时折叠带来的缓存失效可能比省下的贵);主动回合投递前也先看她在不在忙(定时叫醒一直有这个判断,主动回合没有)。
70
+ - **上游断流时那行红字被当成「她开口说了话」(用户报,2026-11-06 01:32)。** ACP 代理在发现输出退化、nudge 之后仍然退化时,会往她那一轮里写一行 `❌ [ACP] stream error: …`。`followWake` 原先只判「有没有非空文本块」,于是这行报错被算成开口:记 `proactive-turn:`、占掉当天额度、留在对话流里、也不走静默折叠。现在先认这类断流信号(`❌` 开头 / 含 `stream error` / 含 `turn degenerated`),认出来就不算开口,按「没开口」那一套记账并尝试折叠。**澄清一句**:这行字不是插件产生的,是上游退化;插件只能保证不把系统报错记成她的行为。
71
+
72
+ ### 迁移
73
+
74
+ - 无必改项。这一版不改事件字段、不升 `EVENT_SCHEMA_VERSION`;旧数据照读,行为只朝「更少误判」的方向变。
75
+ - **新增三个可选配置**(`proactive.sendTools` / `foldSilent` / `idleCountsSeen`),默认值等于旧行为:不填、不配置的部署一处不变。只在「她写的正文不会被自动转发、要靠工具才发得出去」的部署里才需要填 `sendTools`。
76
+ - 有一处**观感上会变**:`her_say` 回执从 `[说出去了]` 改成 `[记下了]`,桌上一句从「还有一件没合上的事」改成「还有一件你反复问起的事」——都是措辞,不动数据。
77
+ - 已经被误改名成 `.corrupt.<时间>` 的老文件不会自动恢复——那是上一版留下的现场,需要的话手动改回原名(内容本身是完好的)。
78
+
79
+ ### 测试
80
+
81
+ 全量 `node --test` = **1512 pass / 0 fail**(0.9.0 是 1441,本版新增 70 条,其中四个社区 PR 带来 29 条)。新增的都是这几条的回归:#33 认最新会话;#34 群房不记 `seen:` / 家里那间照记;#35 手打答案能认出来、日志不落原文;#36 前一轮的 `turn/end` 收不走处境;#37 EPERM 重试 + 一直失败时受超时约束;#38 带 BOM 照读且不改名;#32 聊天折行与 idle、判断与聊天的分界、聊天之后的状态;换房失败不问话、失败后冷却、冷却过去能再试并清账;`carry-ask-*` 判成 plugin。新增里还有:#41 `her_say` 按上限拦、成长坐标不是 1/1;#42 兴趣关掉不再摆旧句子;#43 版本号依赖也能清账、pnpm isolated 布局认得 profile 根。另有 #44 的 20 条(靠工具发话、空闲时钟、忙闲检查、处境与身份段说实话)与 #45 的 1 条(同时写同一个文件不出半截内容)。**#36、换房、#41 那几条已验证能复现**——把判据改回去就红。
82
+
83
+ ---
84
+
85
+ ## 0.9.0 — 2026-11-05
2
86
 
3
87
  这一版八件事(外加 0.8.9 未单独发版的两件事):**她自己会攒劲儿、会惦记、会缺工具就问你要**(不再藏在本机实验里);**梦、主动性、兴趣、主动搭话各有一个开关**;**「让她去刷 B 站」砍掉了**;**劲儿与开口的节奏改成一套数**;**两处会让人看不懂的措辞改成人话**;**桥可以自己决定开几间房、谁是外客**;**群房按天换新**;**40 轮换房不再多建一条会话**。
4
88
 
package/README.md CHANGED
@@ -12,7 +12,12 @@
12
12
 
13
13
  📺 **[看视频讲解(19 分钟)](https://www.bilibili.com/video/BV1oTnZ6LEtc/)** —— 这个插件是怎么想出来的、她为什么会长成那样、13 亿 token 都花在哪了。
14
14
 
15
- > **当前版本 0.9.0**(2026-11-05)。这一版把一直只在开发机上生效的四样东西对所有人打开了:**她会自己开口**(攒够一股想动的劲、你在她的时段里、你确实一阵没说话、桌上有可说的东西,四条同时满足才开一轮;不想说就真的不写,只留一行记录)、**梦里记事**、**醒来没事时自己找点事做**(空醒)、**她会自己攒劲儿、惦记、缺工具就问你要**(主观能动性 / 兴趣)。刷 B 站不再挂在叫醒上(`bili_*` 工具仍可用)。0.9.0 起桥可以自己决定开几间房、每间是谁(`room` / `title` / `guest` 三个可选字段),群房默认满 7 天换新。四个设置卡可以单独关:主动搭话 / 主观能动性 / 兴趣 / 梦。完整说明见 **[0.9.0 更新说明](RELEASE-NOTES-0.9.0.md)**,上一版见 **[0.8.8 更新说明](RELEASE-NOTES-0.8.8.md)**。
15
+ > **当前版本 0.9.2**(2026-11-06)。还是修 bug 版,没有新功能,修的是「她该说话时没说话、说了却没被看见、记下了却没上桌」这一类故障。她不会突然连着几轮只说「已完成分析」、一个字不写了——叫醒时那句「这一轮是你自己的……不想说就什么都别写」以前是整进程共用一份,会串到别的会话;现在按会话分开存、10 分钟硬超时、你在这条会话里一开口就清掉。外客通路(QQ 桥、群聊、别的插件)问一句,`herReply` 里能拿到她真正回的那句话了(以前她只写正文、没调 `growth_commit` 时会被判成「她这次没说话」)。面板不再把「到点了但没东西可摆」写成「醒了」;插件停用后定时器真的停了,再启用不会再新旧两个调度器一起叫她;叫醒一直失败时不再每分钟重试、每分钟往事件流写一行;他回来过一次之后冷落检测还能再触发;第 3 天的卡池不再空着、同一张卡每次换一种问法;扫码页签下也能点「保存」「测试」;200 张便条都划掉之后还能再记新的。完整说明见 **[0.9.2 更新说明](RELEASE-NOTES-0.9.2.md)**。
16
+ >
17
+ > 上一版 **0.9.1**(2026-11-06)是修 bug 版,没有新功能,修了社区报的十条(issue #32–#38、#41–#43)加用户直接反馈的几条,另合入四个社区 PR(#39、#40、#44、#45):面板上「说 N」不再被聊天撑大、归档后认回最近那条会话、群聊不再让冷落检测失灵、卡片里手打的答案能认出来、排队叫醒不会被上一轮清掉、Windows 上 EPERM 会重试、带 BOM 的 JSON 不再被当成坏文件、换房那句「想给下一个房间留一句话吗」不再每回一句都弹(换会话失败时也不问,并冷却 5 分钟再试);「多久看一次有没有人回复」真的按间隔去问了、扫到的回复会主动投给她、`bili_look` 也能读自己的评论区;@ 艾特改按昵称原文匹配,只粘 cookie 保存不再清空 uid / 昵称;`her_say` 说好的「一天最多几次」真的生效了、写下的成长坐标不再是 1/1、回执从「说出去了」改成「记下了」;关掉「兴趣」后不再每 6 小时因为一句旧句子把她叫醒;用 `plugin add` 升级留下的旧包名能清干净,「检查更新」在 pnpm 布局下也认得出装在哪。另外:配置偶尔整个回默认(同一毫秒两次写撞进一个临时文件)也修了;「她靠发送工具发话」的部署现在会正确记成开了口;主动回合投递前会先看她在不在忙。完整说明见 **[0.9.1 更新说明](RELEASE-NOTES-0.9.1.md)**。
18
+ >
19
+ > 上一版 **0.9.0**(2026-11-05)把一直只在开发机上生效的四样东西对所有人打开了:**她会自己开口**(攒够一股想动的劲、你在她的时段里、你确实一阵没说话、桌上有可说的东西,四条同时满足才开一轮;不想说就真的不写,只留一行记录)、**梦里记事**、**醒来没事时自己找点事做**(空醒)、**她会自己攒劲儿、惦记、缺工具就问你要**(主观能动性 / 兴趣)。刷 B 站不再挂在叫醒上(`bili_*` 工具仍可用)。0.9.0 起桥可以自己决定开几间房、每间是谁(`room` / `title` / `guest` 三个可选字段),群房默认满 7 天换新。四个设置卡可以单独关:主动搭话 / 主观能动性 / 兴趣 / 梦。完整说明见 **[0.9.0 更新说明](RELEASE-NOTES-0.9.0.md)**,上一版见 **[0.8.8 更新说明](RELEASE-NOTES-0.8.8.md)**。
20
+ >
16
21
  > **出门仍是实验功能**:它会真的把一条对话交出去,也会读那个工作区的项目笔记和记忆文件;最坏的情况是她把不该看的内容写进日记,而日记撤不回。第一次使用要先确认,风险与回退方式见 **[0.8.0 更新说明](RELEASE-NOTES-0.8.0.md)**。
17
22
 
18
23
  ## 和别的养成插件差在哪
@@ -368,7 +373,7 @@ lib/
368
373
  toolschema.js 简写参数 DSL → 标准 JSON Schema
369
374
  observe.js 宿主侧 JSON API(/dsh-fish/state、/events、/beliefs、/diary、/config、/wake、/browse、/reports、/update)
370
375
  client.js 浏览器半边:设置 → 大肥鱼
371
- test/ 1441 个测试,node --test 全绿
376
+ test/ 1609 个测试,node --test 全绿
372
377
  ```
373
378
 
374
379
  ## 跑测试
@@ -436,9 +441,9 @@ ffmpeg 写文件这件事不需要额外权限:抽帧和拼图都从 stdout
436
441
 
437
442
  > 路径里带中文时,用 ASCII 目录联接(junction)指过去更稳:先 `fs.symlinkSync(真目录, ASCII 别名, 'junction')`,再 `link:<ASCII 别名>`。用 `mklink /J` 接中文目标路径常把目录名写成乱码,随后 `MODULE_NOT_FOUND`。
438
443
 
439
- ## 从旧版升到 0.9.0(含改名与升级修复)
444
+ ## 从旧版升到 0.9.x(含改名与升级修复)
440
445
 
441
- 0.8.9 把包名从 `dsh-fish` 改成了 `@ssamurias/dsh-fish`;0.9.0 又把原本准备在 0.8.10 发的升级修复一起带了进来。老安装分三种,走的路不一样:
446
+ 0.8.9 把包名从 `dsh-fish` 改成了 `@ssamurias/dsh-fish`;0.9.0 又把原本准备在 0.8.10 发的升级修复一起带了进来;0.9.1 补上了「按更新说明用 `plugin add` 升上来」那一类的残留清理。老安装分几种,走的路不一样:
442
447
 
443
448
  | 你当初怎么装的 | 怎么升 | 会发生什么 |
444
449
  | --- | --- | --- |
@@ -446,6 +451,7 @@ ffmpeg 写文件这件事不需要额外权限:抽帧和拼图都从 stdout
446
451
  | `link:` 源码目录 | 先跑 `node scripts/fix-legacy-link.mjs --write`,再启动 | 不跑就起不来:条目名是全名,你本地只有裸名链接,import 失败会让 DSH 直接崩 |
447
452
  | 0.8.9 从 npm 装(包名已经是 `@ssamurias/dsh-fish`) | 手动一条命令:`plugin --profile <profile> add "@ssamurias/dsh-fish@0.9.0"` | 0.8.9 的升级代码只认裸名 `dsh-fish`,自动检查更新会把它判成「开发版 / 不归我管」;客户端半边也还是短 id,设置页可能空着 |
448
453
  | 还停在 0.8.8 或更早(GitHub 装) | 手动一条命令:`plugin --profile <profile> add "@ssamurias/dsh-fish@0.9.0"` | 0.8.8 及更早的升级脚本还是老代码(用 `file:` 装临时 tgz),大概率装不上新名字,卡在旧版本 |
454
+ | 用上面那条 `plugin add "@ssamurias/dsh-fish@..."` 升上来的 | 再跑一次 `node scripts/fix-legacy-link.mjs --profile <profile> --write` | 0.9.1 起这个脚本认得**版本号**依赖(以前只认 `link:` / `file:`,一句「没有老依赖,跳过」就退出 0),会删掉残留的裸名 `dsh-fish` 键与旧 `bundles` 条目,老目录改名成 `.disabled-<时间戳>` 收起来,改之前先备份。不加 `--write` 是预览 |
449
455
 
450
456
  升级完自检三行,缺一行就会「重启后崩 / 设置页没有鱼」:
451
457
 
@@ -465,7 +471,7 @@ node_modules 里 没有裸名 dsh-fish 目录
465
471
  P1(骨架):事件流、派生、阶段门、生物钟、工具、冷落检测、日记、观测台。
466
472
  P2(她真的会刷 B 站并写报告):`lib/bili.js` 只读客户端 + `lib/browse.js` 选题筛读 + `lib/look.js` 的 `bili_look` 工具 + `lib/report.js` 写稿,已接进生物钟。
467
473
 
468
- **已有 1441 个测试全绿**,真接口跑通过一轮(10.8s,含首页+详情+评论+弹幕)。
474
+ **已有 1512 个测试全绿**,真接口跑通过一轮(10.8s,含首页+详情+评论+弹幕)。
469
475
 
470
476
  `browse.enabled` 默认 **false**(0.9.0 起不再由叫醒驱动);想让她醒来顺带刷首页,在 `config.json` 里设回 `true`,关掉后定时器一次都不会调 B 站接口。`bili_*` 工具仍然可用,只是不再挂在唤醒上。
471
477
 
@@ -474,9 +480,10 @@ P2(她真的会刷 B 站并写报告):`lib/bili.js` 只读客户端 + `lib
474
480
  这个插件不依赖任何第三方库(`package.json` 的三个依赖字段都是空的),但想法不是一个人想出来的。
475
481
 
476
482
  - **@吴晓观**(交流群 1 群群友)—— 记忆机制这一块的思路起点来自他。0.8.6 里「固化下来的认知也会随时间退下去」「翻旧事可以先只给清单再给全文」「记忆相关的数字要说得清它数的是什么」这几条,最初都是他在群里讲的。落进本插件时全部重新实现了一遍,没有代码或文字的直接搬运;具体的取舍与改法记在 [CHANGELOG](CHANGELOG.md) 的 0.8.6 一节。
483
+ - **@无**(交流群 2 群群友)—— 主观能动性的思路起点来自对方在群里的一句话:「主动性不该是定时器,而是攒够了的冲动。」0.9.0 的「劲儿攒够才动、兴趣会回灌」就是把这句落成实现;代码全部自写,没有搬运。
477
484
  - **@Bacon-01** —— 0.8.6 的 B 站手机扫码登录(含自写的零依赖二维码编码器 `lib/qr.js`)由他贡献,见 [PR #17](https://github.com/ssamurias/dsh-fish/pull/17)。
478
485
  - **@BBleae**(晴猫)—— 从 0.8.6 起先后提了五个 PR:三个直接合入([#10](https://github.com/ssamurias/dsh-fish/pull/10) 匿名 buvid3、[#11](https://github.com/ssamurias/dsh-fish/pull/11) 叫醒间隔、[#15](https://github.com/ssamurias/dsh-fish/pull/15) 叫醒那一轮不记成「他来过」);[#23](https://github.com/ssamurias/dsh-fish/pull/23) 的复现与改法落进 0.8.8(日报/回忆分三种话、被推翻的话不再排第一);[#26](https://github.com/ssamurias/dsh-fish/pull/26) 修掉回忆 `brief` 档的两处显示问题(已并入 0.9.0)。
479
- - **@BBleae**(晴猫)在 0.9.0 期间又提交了 #27–#31 五个 PR:宿主拼的上下文不算他来过、`wake-spacing` 假宿主探活、`her_note` 按她自己写的话划掉、没认领的聊天行、导出事件流只留元数据。是否并入 0.9.0 见发版流程;合入后会在 0.9.x 说明里补上。
486
+ - **@BBleae**(晴猫)在 0.9.0 期间又提交了 #27–#31 五个 PR:宿主拼的上下文不算他来过、`wake-spacing` 假宿主探活、`her_note` 按她自己写的话划掉、没认领的聊天行、导出事件流只留元数据。这五个 PR 都已并入 0.9.0(合并提交 `85b5b9c`、`72aefed`、`d49738a`、`a21d472`、`c230109`)。
480
487
 
481
488
  原理页里引用的 Kolb / Piaget / Bowlby / Festinger / Bartlett / Dewey / Schön,只是思想引用(页底标了作者与年份),不含他人代码或文字。
482
489
 
@@ -0,0 +1,296 @@
1
+ # 0.9.1 更新说明
2
+
3
+ 2026-11-06。这一版是**修 bug 版**,没有新功能,不改配置键、不改事件字段、不升事件格式版本——装上去旧数据照读。
4
+
5
+ 修的是社区报上来的十条(issue #32–#38、#41–#43,@BBleae),加几条用户直接反馈的问题(换房问话每轮回弹、B 站评论看不到、艾特不生效、cookie 观感),并合入四个社区 PR(#39、#40、#44、#45)。都在本地复现过,**都是我们这边的错**,不是用户配置问题。按「你会看到什么」排:
6
+
7
+ - **面板上「说 N」不再被聊天撑大,「记下 N 条」旁边多了口径说明**(#32)。
8
+ - **当前会话被归档后,认回来的是最近那条,不再是库里最旧那条**(#33)。
9
+ - **群聊房里她写东西不再算「你来过」,冷落检测恢复正常**(#34)。
10
+ - **卡片里手打的答案能认出来了;你手打的私事不再落到日志里**(#35)。
11
+ - **排队叫醒不会被上一轮的收尾清掉**(#36)。
12
+ - **Windows 上建会话偶尔失败(EPERM)不再一次就放弃**(#37)。
13
+ - **带 BOM 的 JSON 不再被当成坏文件改名、内容「凭空消失」**(#38)。
14
+ - **「想给下一个房间留一句话吗」不会每回一句都弹了**;换会话失败时也不再问、并冷却 5 分钟再试。
15
+ - **「多久看一次有没有人回复」真的按间隔去问了;扫到的回复会主动投给她**;`bili_look` 也能读自己的评论区了。
16
+ - **@ 艾特按昵称原文匹配,不再被改成下划线**;只粘 cookie 保存也不再清空 uid / 昵称。
17
+ - **cookie 输入框空着是设计**,文案补了说明;「看不到评论」与 cookie 无关,是 B 站按 IP 的间歇风控。
18
+ - **`her_say` 真的按「一天最多几次」拦住了**;它写下的成长坐标也不再是写死的 1/1(#41)。
19
+ - **回执从「说出去了」改成「记下了」**——那一步本来就只记流水,不会让她的话出现在你的对话里(#41)。
20
+ - **关掉「兴趣」之后,不会再每 6 小时因为一句旧句子把她叫醒**(#42)。
21
+ - **用 `plugin add` 升级留下的旧包名能清干净了**;「检查更新」在 pnpm 布局下也认得出装在哪(#43)。
22
+
23
+ ---
24
+
25
+ ## 一、面板上的数字(#32)
26
+
27
+ ### 你会看到什么
28
+
29
+ 时间线里不再出现几十行「说了:…」。她一次醒来跟你连聊很多句时,那一段会折成**一行「聊了 N 句」**。面板上原来的 `说 N` 会把聊天行减掉,聊天另起一个 `聊天 N`。
30
+
31
+ 「她记下 N 条」这个数字本身**没变**,但鼠标停上去现在会说清楚它数的是什么。
32
+
33
+ ### 为什么会这样
34
+
35
+ 两件事。
36
+
37
+ **第一件:聊天行混进了「她做的事」。** 你在侧栏跟她说话,她每回一句,那一轮结束时都会在事件流里落一条 `utterance`(标着 `uncommitted`,意思是「这句是对话里回的,不是她自己认下的」)。可面板读事件流时没看这个标:它跟「她自己认下的判断」一样算进 `said`,也一样往时间线上推一行「说了:…」。
38
+
39
+ 于是两个症状:
40
+
41
+ - 你连说四十几句,时间线 40 条的上限全被聊天占掉,那一轮真正做了什么反而看不见;
42
+ - 那一块本来「醒了但没干事」,因为塞了聊天行,被算成「干了事」——`idleWakes` 直接掉到 0。
43
+
44
+ 现在带 `uncommitted` 的行单列成新的 `chat` 计数:不进 `said`、不算这一轮做了什么、时间线上同块内连续多句折成一行。**事件流一个字节没动**,只是读法变了。
45
+
46
+ **第二件:「她记下 N 条」对不上。** 那个数字来自 `countEvents().real`,它数的是「所有不是系统记账的事件」——包括**你**说过的话(`heard`)、她读到并记下的别人说法(`voice`)、开局那份起点(`seed`)。文案只写「她记下」,看起来就像「她写了 N 条」,也就难怪一直对不上。
47
+
48
+ `real` 的定义这次**不改**:动它会牵一大串派生数字(`/state`、`/events`、设置页三处都读它),而且有测试逐字段钉着它的返回形状。改的是把话说清楚——两处文案各加一句鼠标悬停说明,讲明这个数包含什么、不包含什么(系统后台记账另计)。
49
+
50
+ ## 二、归档后认错了会话(#33)
51
+
52
+ ### 你会看到什么
53
+
54
+ 你在侧栏把当前那条会话归档了,而她家同屋还有两条以上活会话时,以前她可能认回**最旧**那条继续住。现在认最新那条。
55
+
56
+ ### 为什么会这样
57
+
58
+ 宿主在 workspace 记录里写会话 id 是**往前插**的:`sessionIds: [新id, ...旧的]`。我们的 `pickLiveSession` 取的是 `live[live.length - 1]`——末尾,也就是最旧那条,注释还写着「宿主按创建顺序往追加,末尾即最近那条」。方向反了。
59
+
60
+ 现在改成取 `live[0]`,注释一起改,测试里的假宿主也从 `push` 改成了 `unshift`,跟真宿主对齐。
61
+
62
+ **还有一件事没解决**:你在侧栏拖动过会话顺序时,`live[0]` 是列表最上面那条,不一定最新。要严格按时间排得问宿主的 `sessionController.list()` 拿真实时间戳。这次只修明显反了的方向。
63
+
64
+ ## 三、群聊让冷落检测失灵(#34)
65
+
66
+ ### 你会看到什么
67
+
68
+ 她不再因为你没出现而装作没发现。以前群一热闹,她就以为「他今天来过」,那条「那个人有 N 天没出现」的记账永远不会写下;现在会正常写。
69
+
70
+ ### 为什么会这样
71
+
72
+ 插件判断「你来过」不靠聊天内容,靠一条专门的记账行 `meta seen:<时间>`。冷落检测(`absence.js`)只看最近一道 `seen:` 是多久以前;默认超过 2 天(配置项 `absenceAfterDays`)就落一条 absence。
73
+
74
+ 写 `seen:` 的地方有三处:你在侧栏打字(`turns.js`,主路)、插件替你把外面渠道的话递进来(`say.js`)、以及**她当场写东西时顺手补一条**(`capture.js`)。第三处是兜底:有些渠道进来的消息,`turns.js` 分不清是不是人(比如带 `kind:user` 却没带时区,判成「不知道」→ 不记 `seen:`),这时如果她这一轮刚好写了东西,就补一条,免得漏记。
75
+
76
+ 问题就在这第三处:它只问了一句「现在在不在叫醒窗口里」,**没问这句话是在哪间房说的**。群房里群友说一百句,她每回一句都调 `growth_commit` 记下,每次都落在窗口外 → 每次都刻一道 `seen:` → 冷落检测永远不触发。
77
+
78
+ 现在写之前先翻 `channels.json`(房间登记表):这条会话登记成 `group` 就跳过,不是就照写。
79
+
80
+ ### 为什么不干脆删掉这段
81
+
82
+ 试过。删掉会红 4 条测试(`wake-spacing`、`p1` 里都钉着「窗口外写的要记 `seen:`」),而且真会漏记——上面说的「带 `kind:user` 但没时区」那类消息全靠这段兜底。
83
+
84
+ ### 只跳群房,不跳另外两处
85
+
86
+ 另外两种「窗口外、但其实是插件自己把她推醒」的轮:换房前问她上一间还有没有话(`home.js:753`)、定时任务与 goal。这两处**照旧记** `seen:`,这是 2026-11-05 用户确认过的口径:
87
+
88
+ - 换房前那句通常是在应你上一轮的话,算你来过的证据;
89
+ - 定时任务分不出「她自言自语」和「她在回你」,要分开得先给每种叫醒打标记,不在这一版做。
90
+
91
+ ## 四、手打的答案与日志(#35)
92
+
93
+ ### 你会看到什么
94
+
95
+ 在卡片里直接**打字**回答(而不是点按钮),她现在能认出来并照做。以前一律当成「没答」,白白等到超时。
96
+
97
+ 另外,你手打的私事不会再原样落到 `logs/dsh-fish.log` 里。
98
+
99
+ ### 为什么会这样
100
+
101
+ 宿主返回的答案形状是 `{ id, selected: [], custom: '你打的字' }`——选了自由文本时,`selected` 是**空数组**,内容全在 `custom` 里。我们以前只读 `selected[0]`,空数组于是取到 `undefined`,一律判成 `NO_MATCH`。
102
+
103
+ `askinchat.js` 里早就有一份写对的 `picked()`(非空 `selected` 优先,否则退回 `custom`),`ask.js` 没用它。现在改用同一个函数。
104
+
105
+ 日志那半:认不出的时候,原来会把整份回执 `JSON.stringify(...)` 塞进 warn 日志。你手打的那段话就这么落进了日志文件。现在换成形状摘要——只留 id、选了几个、自定义文本多长。
106
+
107
+ ## 五、排队叫醒被上一轮清掉(#36)
108
+
109
+ ### 你会看到什么
110
+
111
+ 你点「叫她一次」时,如果她正在别的轮次里忙,这条叫醒以前可能**排进下一轮却什么都没收到**——处境段被上一轮的收尾清空了,她醒过来一脸空白。现在会等到真正属于她那轮结束。
112
+
113
+ ### 为什么会这样
114
+
115
+ `watchTurnEnd` 认**会话**不认**回合**:只要这条会话上出现 `turn/end`,就当成「她这一轮结束了」,把处境段收走。
116
+
117
+ 而投递走的是宿主的 `followup`:宿主忙时会把它放进 next-turn 队列,当前那一轮先 `turn/end`,然后才开下一轮。于是那个 `turn/end` 一到,处境还没送达就被清了。
118
+
119
+ `waitIdle` 能挡住大部分情况,但有两处绕过它:手动「叫她一次」的 `wakeNow` 不传 `waitIdle`,`/wake` 路由也不看忙闲;手动这条路还有 90 秒兜底,到点就清。
120
+
121
+ 现在要求「监听挂上之后**先见到本条会话的 `turn/start`**,之后的 `turn/end` 才算」。拿不到事件总线时(单测、老宿主)退回老行为,靠 `waitIdle` 兜。
122
+
123
+ 这条回归**已验证能复现**:把判据改回去,新加的那条测试立刻红。
124
+
125
+ ## 六、Windows 上的 EPERM(#37)
126
+
127
+ ### 你会看到什么
128
+
129
+ Windows 上偶尔看到「建会话的锁没拿到:EPERM…」,然后这一次换会话就失败。现在这种暂时性的失败会重试,不再一次就放弃。
130
+
131
+ ### 为什么会这样
132
+
133
+ `withFileLock` 用 `fs.open(file, 'wx')` 抢锁。Windows 上文件**正在被删除**的那一刻去打开,返回的是 `EPERM` 而不是预期的 `EEXIST`。我们的代码只把 `EEXIST` 当「锁被占着」,其余一律 `throw`——于是 EPERM 直接被抛到上层。
134
+
135
+ (这也是 `test/tenant.test.mjs` 那条用例时好时坏的原因:单跑 25 次,23 过 2 红。)
136
+
137
+ 现在 EPERM 走独立的重试分支,**受 `timeout` 约束**,且只认 win32。
138
+
139
+ ### 为什么不能并进 EEXIST 分支
140
+
141
+ EEXIST 那条路里,`stat` 失败就判「锁陈旧」然后 `continue` 重试——但它**不看超时**。真正的权限错误在那条路里会变成死循环。必须单独一条路,每一步都检查 `Date.now() - started > timeout`。
142
+
143
+ (issue 作者压测过:12 并发 × 400 次,改前 4113 成功 / 687 抛,改后 4800 成功 / 0 抛。)
144
+
145
+ ## 七、带 BOM 的 JSON(#38)
146
+
147
+ ### 你会看到什么
148
+
149
+ 用 PowerShell 5.1 的 `Set-Content -Encoding UTF8` 或记事本改过配置文件的人,以前会发现**改的东西全没了**——配置退回默认、主人信息变 null、她家在侧栏新建了一条会话。现在照常读出来。
150
+
151
+ ### 为什么会这样
152
+
153
+ 这几个文件都是 UTF-8 **带 BOM** 的。`readJson` 直接 `JSON.parse(file)`,开头的 `\uFEFF` 让解析抛 `SyntaxError`,而 SyntaxError 走的是「坏文件」分支:把**内容完好**的文件改名成 `<文件名>.corrupt.<时间戳>`,返回兜底值。
154
+
155
+ 具体后果:
156
+
157
+ - `config.json` → 全默认(`settings.js` 的 `fresh: true`);
158
+ - `owner.json` → null;
159
+ - `home.json` → 新建一条会话。
160
+
161
+ 现在读之前先剥掉开头那一个 `\uFEFF`。`lib/upgrade.js` 里自己那份 `readJson` 是同样的坑(带 BOM 时读成 null,升级自检当成没装),一起修了。
162
+
163
+ ---
164
+
165
+ ## 八、换房那句问话每回一句都弹(用户反馈,2026-11-06)
166
+
167
+ ### 你会看到什么
168
+
169
+ 聊天聊到该换会话的时候,她那边每次说完话、你这边就冒出一条:
170
+
171
+ > (你在这个房间里已经说了不少话。屋子要换一间新的了——还是同一间屋子,只是对话重新开始。)
172
+ > 想给下一个房间留一句话吗?留什么都可以。不想留就说「不用」。
173
+
174
+ 而且**答了也没用**——回「不用」之后,下一次她说完话,这条还会来。
175
+
176
+ ### 为什么会这样
177
+
178
+ 两个错叠在一起。
179
+
180
+ **第一件:问话问得太早了。** 换会话的动作分几步:问一句留话 → 旧会话改名留档 → 建新会话 → 把原话和轮数搬过去。原来「问一句」排在**第一步**,而它用的 `sc.prompt({ mode: 'queue' })` 投递的是**她此刻正在用的那条旧会话**——也就是你眼前的对话窗口。所以这条问话会像一条普通消息一样直接出现在你俩的对话里。
181
+
182
+ **第二件:换不成就无限重试。** 只要「建新会话」这一步抛错(宿主没起来、工作区 id 丢了、Windows 上那个 EPERM,见第七条),函数当场返回,`turns` 也不会清零。下一轮你说完话、她答完,结账时又发现「到点了」,于是又进来、又先问一遍。你看到的就是每回一句都弹。
183
+
184
+ 这也解释了为什么它「答了也没用」:那句「不用」根本没有让流程往前走,卡住的是建会话那一步。
185
+
186
+ ### 现在怎么改的
187
+
188
+ - **问话挪到新会话建好之后。** 换成功了才问她留不留话;换不成就一个字都不发——不在你的对话里制造噪音。
189
+ - **换失败记一笔时间,5 分钟内不再试。** 以前是每说完一句就去撞一次宿主、每次都留一条问话;现在 5 分钟内只试一次。真修好了(宿主恢复)下一次到点自然就换成了。
190
+ - **顺手补了个分类遗漏。** `carry-ask-*` 这条问话的 `rpcId` 前缀没写进 `PLUGIN_RPC`,于是它一直被当成「认不出来源的消息」,每换一次会话就在日志里多一行「这条消息认不出来源,没算进轮数」。不进轮数是对的,日志噪音不该有。
191
+ - **设置页那句「上下文占满后会自动换」是空话。** 代码里没有这个功能。设「0 = 永不换」的人会一直聊到宿主上下文撑不住、那一轮直接失败。文案改成说实话。
192
+
193
+ ### 顺带说明:这跟「轮数设 0」没关系
194
+
195
+ 「永不换会话」这个设置**一直是生效的**:两个换会话入口(`lib/turns.js`、`lib/say.js`)前面都有 `rotateAfter > 0` 的守卫,设成 0 根本进不到换会话的流程里,也就不会问那句话。所以反馈里猜的「没适配永不换」不是真因,真因是上面那个「换失败后无限重试」。不过既然提到了,就顺手把其他叫醒路径都过了一遍:
196
+
197
+ | 叫醒/投递 | 出处 | 要适配 `rotateAfter = 0` 吗 |
198
+ | --- | --- | --- |
199
+ | 新会话第一次叫醒 | `lib/home.js` `wakeIfBlank` → `wake-fresh-*` | 不用。它跟轮数无关,只在「屋子是空的」时候说一句,用 `wokeFor` 去重。 |
200
+ | 出门第一轮 | `lib/observe.js` → `outing-*` | 不用。由出门开关控制,不看轮数。 |
201
+ | 定时任务/主动叫醒 | `lib/deliver.js` `deliverWake` → `agent.followup` | 不用。走的是自建来源 `fish-wake`,不经过 `sc.prompt`。 |
202
+ | 排队的驳回话术 | `lib/sessionnote.js` `queueNote` / `deliverQueued` | 不用。只在她家会话开着时投递,与轮数无关。 |
203
+ | 群房按天换 | `lib/channels.js` `channelExpired` | 已经对了。`!(d > 0)` 直接返回 false,设 0 就是不换。 |
204
+
205
+ ---
206
+
207
+ ## 九、B 站评论看不到、艾特不出来、cookie「填完就消失」(用户反馈,2026-11-06)
208
+
209
+ 这三件事是一起报上来的,查下去是**三个独立的毛病**,另外还牵连一个认知误区(以为跟 cookie 有关)。
210
+
211
+ ### 你会看到什么
212
+
213
+ - **评论**:开了「允许看评论」、也把「多久看一次」设成 5 分钟,仍然一条回复都收不到;等她被叫醒时偶尔又冒出来一条。现在到点就会去问,收到就直接投一条到她那儿。
214
+ - **她主动看评论**:以前她只会说「我只能拿热评,翻不到自己的评论区」。现在 `bili_look` 多了 `what=replies`,能读到「谁在你哪条评论下面说了什么」,还带着回楼中楼要用的编号。
215
+ - **艾特**:设置里写了「发评论时在最前面 @ 你」,实际评论里只有 `@` 或者根本没 @。
216
+ - **cookie**:粘进去点保存,输入框立刻空掉,让人以为没存上。
217
+
218
+ ### 为什么会这样
219
+
220
+ **一、「多久看一次」是个空旋钮。** `comment.scanEveryMin`(默认 30,范围 5–720)从加进设置页那天起就没人读:全仓库只有设置页读写和 `/comment` 回显,调度器一次都没用过;配套的 `comment.lastScanAt` 更是零读写。真正的扫描只发生在「她下次被叫醒、拼消息那一轮里顺便扫一句」——她不醒就永远不扫。设 5 分钟和设 720 分钟,实际表现完全一样。
221
+
222
+ 修法:`tick` 每分钟看一眼「到点没有」(`repliesScanDue`),到点就扫,**扫完无论有没有都记一笔 `lastScanAt`**(不记的话每 30 秒就去敲一次 B 站);扫到新回复就用 `queueNote` + `deliverQueued` 投一条笔记进她的会话——和驳回话术那套投递是同一个通道。设 0 仍然表示「不主动问」。
223
+
224
+ **二、她醒着的时候没有「看我自己的评论区」这个动作。** `bili_look` 只有首页 / 搜索 / 接着看 / 看某一条四个动作,没有任何一个是「我刚才发的那条下面有没有人回」。`bili_speak` 回楼中楼又必须要 `parentRpid`,而那个编号她手上根本拿不到——于是「知道有人回了,但回不了」。
225
+
226
+ 新增 `what=replies`:读她最近发过的评论下面的楼中楼,**不受「已读过」标记限制**(她主动看就该看全),每行末尾给出 `[reply=… parent=…]`,回楼中楼用哪个编号一眼可见。
227
+
228
+ **三、@ 艾特被自己改坏了。** `mentionPrefix` 原来会把昵称里的空格换成下划线再拼 `@`。真机抓到的真实 @ 评论里,`content.members` 里的 `uname` 和正文里的写法一字不差——B 站按昵称**原文**匹配,换了下划线就是 @ 一个不存在的人。
229
+
230
+ 另外还有个更常见的前提:**昵称为空就根本不加 @**(这是有意为之,空的 `@` 只是一串没人认领的字符)。所以「设置了能艾特却艾特不出来」,先看那两格填没填。
231
+
232
+ **四、只粘 cookie 保存一次,会把 uid / 昵称清空。** 保存按钮原来无条件提交 `{mid, nickname}`,空串也照发;后端对空串是「写入空值」。用户只粘 cookie、那两格没填,存一次就把 `@` 的对象抹掉了——这跟第三条叠在一起,就是「艾特完全不生效」。现在三格各自只在非空时提交,全空直接提示「没东西可存」。
233
+
234
+ **五、cookie 显示空白是设计。** 接口从来不下发 cookie 原文(只给「存没存」、「有没有 bili_jct」两个布尔),输入框保存后清空是为了防窥屏。原本文案只写「不再回显」,容易读成「没存上」,补一句「空着不代表没存上,上面那行『已存…』才是凭据」。
235
+
236
+ ### 顺带说清:看不到评论跟 cookie 没关系
237
+
238
+ `scanReplies` 用的是**匿名**客户端,本来就不带 cookie。本机真机实测:同一份 cookie,`popular` 能读,`/x/v2/reply` 和 `/x/v2/reply/reply` 一律 `-412 request was banned`;同一台机器早些时候又能读通——这是 B 站按 IP 的间歇风控,插件这边只做冷却退避(读接口 60 秒起、翻倍、上限 10 分钟)。
239
+
240
+ 所以「看不到评论」排查顺序应该是:① 设置里那个开关开了没;② 日志里有没有 `-412`(有就是撞上风控,等一会儿或换网络);③ 有没有真的发过评论——没有发过,就没有「自己的评论区」可扫,这是设计,不是故障。
241
+
242
+ ---
243
+
244
+ ## 十、主动开口的账、旧句子空醒、旧包名升级(issue #41–#43,2026-11-06)
245
+
246
+ ### 你会看到什么
247
+
248
+ **一、`her_say` 说好的上限是真的。** 设置里写「一天最多几次」,以前这句话只在说明文书里成立:连调 8 次全成功,开关关着、上限设 0 也照写。现在到上限就记不进去,回一句「今天已经主动开口 N 次,到上限了。明天再说;这不是工具坏了」。
249
+
250
+ **二、她主动开口时记下的成长坐标不再是写死的 1/1。** 别的写入点(日记)都用 `deriveSelf` 算出的 `{day, stage}`,只有这里硬编码成第 1 天第 1 阶段,于是成长一致性检查会跟她早期的行错配对——阶段 1 少算一对、阶段 4 多算一对。改成跟日记同一套算法。
251
+
252
+ **三、回执改名:`[说出去了]` → `[记下了]`。** 那一步只是往她自己的流水里记一行,你的对话里看不看得见,取决于她这一轮有没有直接写出来。原来那个名字会让她(和你)误以为消息已经发出去了。
253
+
254
+ **四、关掉「兴趣」之后,桌面不再摆旧句子。** 以前只要桌上有旧句子、距上次醒来超过 6 小时,就叫醒——而且这一轮**不摆事件卡、不写 `fired:`、不想一想、不递规则申请、「今天到这里」也不给**。结果是卡池永远空着、想一想永远轮不到。作者用 14 天合成数据跑出来:叫醒 14 次、14 次全是旧句子、事件卡 0、想一想 0。现在「兴趣」关掉就不摆旧句子,这一轮回到正常的空醒判断。
255
+
256
+ **五、三句说死了的话松开了。** 「这次隔了 N 个小时」其实只数 `wake:`(她主动开口、你跟她聊的那些都不算),改成「距你上次醒来,隔了 N 个小时」;「这些天再没被提起过」的判据只是「之后有没有事件 `refs` 指回它」,改成「还摊在那儿」;桌上的「还有一件没合上的事」暗示她还没得到答案,其实只是她反复问过,改成「还有一件你反复问起的事」。设置页兴趣卡里那句「问过还没答案的问题」一起改。
257
+
258
+ **六、用 `plugin add` 升级之后,旧包名能清干净了。** 按更新说明执行 `dsh plugin add "@ssamurias/dsh-fish@0.9.0"` 的人,profile 里留下的是**版本号**依赖;而清理脚本原来只认 `link:` / `file:` 两种写法,一句「没有老依赖,跳过」就退出 0——旧键 `dsh-fish` 与旧 `bundles` 条目原封不动,下次 pnpm 清理时两条 fish 打架。现在三种写法(源码目录 / tgz / 版本号)都收拾,写入前先备份 `package.json.bak-fishrename-<时间戳>`。
259
+
260
+ **七、「检查更新」在 pnpm 的 isolated 布局下也能显示下载按钮了。** 从模块路径反推 profile 根时,遇到 `node_modules/.pnpm/<包>@<版本>/node_modules/<包>/` 这种真身路径,以前会截到 `.pnpm/...` 那一层——那里没有 `package.json`,于是判成「没装」,`managed:false`,按钮永远不出现。现在往回退到 profile 根。
261
+
262
+ **八、两个社区 PR 已合入**(@BBleae):#40 主动搭话关着时不再每轮同步算一遍 `deriveSelf`(8000 条事件实测从 7.6 秒降到 50 毫秒级);#39 「只留证」的事件(聊天行、带 `selfmod` 的说法)不再被当成她问过的 / 总说到的摆回桌上,也不算她「自己动过」。
263
+
264
+ **九、又是一对社区 PR(#44、#45)已合入**(@BBleae):
265
+
266
+ - **配置偶尔整个回默认,是它自己撞坏的。** `writeAtomic` 写文件时先写临时文件再改名,而临时文件名只有「进程号 + 毫秒」。同一毫秒里两次写(设置页自动保存、调度器记账撞在一起)会共用一个临时文件,内容交错,改名后就是半个 JSON。坏文件进 `readJson` 的坏档分支被收走并返回 `null`,`updateConfig` 按默认值处理再写回去——`window`、`wakesPerDay`、`proactive.enabled` 这些就都回到默认了。现在临时文件名带 4 字节随机后缀。
267
+ - **「她靠工具发话」的部署现在记对了账。** 有的部署里她的正文不会自动转发,要靠一件发送工具才发得出去。这种部署上主动回合只认正文,于是她明明发出去了却被记成「没开口」:不占额度、不起冷却,够长的还被折成一行墓碑,模型侧之后看不到自己发过什么。新增 `proactive.sendTools`(填发送工具名,工具调成功才算开口)、`proactive.idleCountsSeen`(`seen:` 也算打断安静,不然「你刚聊完」它以为「好久没人理你」)、`proactive.foldSilent`(上下文很大、靠缓存省钱时关掉折叠)。处境段与身份段也会照实写「用这个工具发、正文发不到聊天里」。
268
+ - **主动回合投递前也看她在不在忙了。** 定时叫醒一直有这个判断,主动回合没有——她正开在别的一轮里时投递,处境段挂在旧轮上、旧轮一结束就被清掉,真醒的那一轮只收到一个空格。现在两边都看本进程的忙碌表,也认别的插件发来的 `dsh-fish/busy` 事件(谁在忙谁自己说,不用互相知道包名)。
269
+
270
+ ### 需要你动手的一步(只影响从 0.9.0 以前升级的人)
271
+
272
+ 如果你当初是用 `plugin add` 装的,profile 里可能还留着旧的 `dsh-fish` 键。手动跑一次:
273
+
274
+ ```bash
275
+ node scripts/fix-legacy-link.mjs --profile desktop --write
276
+ ```
277
+
278
+ 不加 `--write` 是预览,只告诉你它会改什么。它会把旧键、旧 `bundles` 条目清掉,老链接改名成 `.disabled-<时间戳>` 而不是删除。
279
+
280
+ ---
281
+
282
+ ## 迁移
283
+
284
+ **无必改项。** 这一版不改事件字段、不升 `EVENT_SCHEMA_VERSION`;旧数据照读,行为只朝「更少误判」的方向变。
285
+
286
+ 新增三个**可选**配置(`proactive.sendTools` / `foldSilent` / `idleCountsSeen`),默认值等于旧行为——不填、不配置的部署一处不变。只有「她写的正文不会被自动转发、要靠工具才发得出去」的部署才需要填 `sendTools`。
287
+
288
+ 一处**观感上会变**:`her_say` 的回执从 `[说出去了]` 改成 `[记下了]`,桌上一句从「还有一件没合上的事」改成「还有一件你反复问起的事」。都是措辞,不动数据。
289
+
290
+ 一件要说明的:**已经被误改名成 `.corrupt.<时间>` 的老文件不会自动恢复**。内容本身是完好的,只是名字被改了;需要的话手动改回原名即可(这次决定不自动扫、不自动改回)。
291
+
292
+ ## 测试
293
+
294
+ 全量 `node --test` = **1512 pass / 0 fail**(0.9.0 是 1441,本版新增 70 条,其中四个社区 PR 带来 29 条)。
295
+
296
+ 新增的:#33 认最新会话;#34 群房不记 `seen:` / 家里那间照记;#35 手打答案能认出来、日志不落原文;#36 前一轮的 `turn/end` 收不走处境;#37 EPERM 重试 + 一直失败时受超时约束;#38 带 BOM 照读且不改名;#32 聊天折行与 idle、判断与聊天的分界、聊天之后的状态;换房失败不问话、失败后冷却 5 分钟、冷却过了能再试并把失败记录清掉;`carry-ask-*` 判成 plugin;`repliesScanDue` 按间隔生效且 0 = 不主动问;`readMyReplies` 不受已读标记限制、带 bvid 只看一条;`renderMyReplies` 带编号与素材声明;`tick` 真的按「多久看一次」去问并记下时间;`what=replies` 能读出回复且不写「看过」;`mentionPrefix` 保留昵称原文。还有:#41 `her_say` 按上限拦、成长坐标不是 1/1;#42 兴趣关掉不再摆旧句子;#43 版本号依赖也能清账、pnpm isolated 布局认得 profile 根。另有 #44 的 20 条(靠工具发话、空闲时钟、忙闲检查、处境与身份段说实话)与 #45 的 1 条(同一毫秒同时写同一个文件不出半截内容)。**#36、换房、#41 那几条已验证能复现**——把判据改回去就红。