@ssamurias/dsh-fish 0.8.9 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AGENTS.md +32 -9
  2. package/CHANGELOG.md +84 -2
  3. package/LICENSE +7 -0
  4. package/README.md +64 -17
  5. package/RELEASE-NOTES-0.8.0.md +87 -0
  6. package/RELEASE-NOTES-0.8.1.md +93 -0
  7. package/RELEASE-NOTES-0.8.2.md +57 -0
  8. package/RELEASE-NOTES-0.8.3.md +71 -0
  9. package/RELEASE-NOTES-0.8.4.md +47 -0
  10. package/RELEASE-NOTES-0.8.5.md +244 -0
  11. package/RELEASE-NOTES-0.8.6.md +224 -0
  12. package/RELEASE-NOTES-0.8.7.md +202 -0
  13. package/RELEASE-NOTES-0.8.8.md +224 -0
  14. package/RELEASE-NOTES-0.9.0.md +267 -0
  15. package/THIRD-PARTY-NOTICES.md +3 -0
  16. package/lib/away.js +7 -2
  17. package/lib/channels.js +53 -13
  18. package/lib/client.js +99 -49
  19. package/lib/compact.js +26 -1
  20. package/lib/derive.js +10 -6
  21. package/lib/desk.js +1 -1
  22. package/lib/dream.js +3 -0
  23. package/lib/dreamtable.js +15 -0
  24. package/lib/drive.js +44 -7
  25. package/lib/freewake.js +17 -1
  26. package/lib/home.js +54 -2
  27. package/lib/inbox.js +7 -2
  28. package/lib/index.js +8 -12
  29. package/lib/interest.js +23 -3
  30. package/lib/log.js +22 -1
  31. package/lib/look.js +2 -2
  32. package/lib/note.js +35 -13
  33. package/lib/notify.js +2 -0
  34. package/lib/observe.js +13 -7
  35. package/lib/outing.js +2 -0
  36. package/lib/persona.js +2 -1
  37. package/lib/proactive.js +12 -7
  38. package/lib/say.js +104 -58
  39. package/lib/scheduler.js +67 -37
  40. package/lib/selfcheck.js +2 -3
  41. package/lib/selfdoc.js +3 -6
  42. package/lib/selfupdate.js +176 -41
  43. package/lib/settings.js +28 -2
  44. package/lib/turns.js +41 -3
  45. package/lib/update.js +14 -0
  46. package/lib/upgrade.js +19 -3
  47. package/lib/voices.js +2 -0
  48. package/package.json +4 -2
  49. package/scripts/fix-legacy-link.mjs +147 -0
@@ -0,0 +1,267 @@
1
+ # 0.9.0 更新说明
2
+
3
+ 2026-11-05。0.9.0 是一次大版本。
4
+
5
+ 这一版把四个一直只在开发副本里生效的功能,对所有人打开了:**梦**、**主观能动性**、**兴趣**、**主动搭话**。同时把「桌上有什么」并成一张桌子,把「刷 B 站」从叫醒路径上摘下来,并修掉了几个真机才暴露的问题——包括通过 QQ 桥在 40 轮整换房时会多建一条会话的那个。
6
+
7
+ 原来准备单独发的 0.9.1 桥接改动,也并进这一版;不单独发 0.9.1。0.8.9 也没有单独的 GitHub Release(npm 上的 0.8.9 是改名首发),它的两件事同样并进这一版。
8
+
9
+ 按「你会看到什么」排:
10
+
11
+ - **她会自己开口**,但四个条件要同时满足(见第一节)。
12
+ - **她会攒劲儿、会惦记、缺工具直接跟你要**(主观能动性 / 兴趣)。
13
+ - **梦**:她做过的梦,在住所页能看到。
14
+ - **空醒**:醒来没事时,她可以自己找点事做。
15
+ - **QQ 桥**:可以自己决定开几间房、谁是外客;群房按天换新。
16
+ - **设置页四张卡**:主动搭话 / 主观能动性 / 兴趣 / 梦,可以各自关。
17
+ - **刷 B 站不再由叫醒驱动**(`bili_*` 工具仍然可用)。
18
+ - **修掉**:劲儿恒 0.00、安静时钟被别的会话清零、40 轮换房多建一条会话、「没什么要说的」被算成一次开口。
19
+
20
+ ---
21
+
22
+ ## 一、她会自己开口(主动搭话)
23
+
24
+ ### 你会看到什么
25
+
26
+ 有时候你打开对话框,会看到她先说了话——不是回答你,而是她自己想起来要说的。那条消息是一条**默认收起的折叠条**(「收到执行请求」,展开是「这条通知触发了本轮回复。」)。这是宿主对「不是人发的消息」的默认画法,插件改不了标题与样式(0.9.0 复核过宿主前端,只认 `source.kind`,`form` / `summary` 对这条路径无效)。
27
+
28
+ ### 什么时候会开口
29
+
30
+ 四个条件同时满足才开一轮:
31
+
32
+ 1. **她自己攒够了一股想动的劲**(内部叫「劲儿」,0 到 1,默认按每小时 0.12 的速度攒,到 0.8 算攒满;桌上有材料时更快,四小时上下就能到;开过一次之后要重新攒,两次之间至少隔两小时)。
33
+ 2. **现在在她的醒着时段里**(默认 12:00–02:00,可在 `config.json` 的 `window` 调)。
34
+ 3. **你确实有一阵没跟她说话了**(`minIdleHours`,默认 6 小时,下限 1 小时)。
35
+ 4. **桌上有可说的东西**(见第二节)。桌上空的,劲儿再满也不动——这条是 0.9.0 加的。
36
+
37
+ 攒满了也不是每次都开口:她醒过来看一眼,发现自己没什么想说的,就什么都不说。
38
+
39
+ ### 默认是关的
40
+
41
+ `proactive.enabled` 默认 `false`。0.9.0 让这个功能公开可见,但没有替老用户把它打开——这样升级上来的机器不会突然多出一个会自己说话的鱼。想用就去设置页的「主动搭话」卡打开;关掉只是以后不再自己开口,已经写下的话一条不删。
42
+
43
+ ### 不想说就真的不写
44
+
45
+ 以前她「没什么想说的」,还是会在对话里留一句「这一轮没什么要说的」。现在不会了:那一轮只留一行记录,对话里什么也不出现。提示词里把「什么都不做」改成「不想说就什么都别写:不要写任何字,也不要解释为什么不说」;真的只写了一句元话语时,按「没开口」记账——不占当天次数、不进对话流,折叠成一行。判据在 `lib/compact.js` 的 `looksLikeNothingSaid()`,超过 60 字一律不算元话语。
46
+
47
+ ### 为什么不是按表打卡
48
+
49
+ 定时叫醒是「按点开一轮」,主观能动性是「攒够了才有的冲动」。两者的区别不在频率,在于**这一轮有没有由头**。所以劲儿不是一个定时器:它看的是「距上次动过了多久」「桌上是不是多了东西」「你回来过没有」,以及「上次她开口有没有人接」——上次没人接,这次就攒得慢一点。
50
+
51
+ ### 花多少
52
+
53
+ 开一次约 1.5 分钱(实测 `¥0.0146`)。想省就把「一天最多几次」调到 1,或者把「你多久没说话」调大。
54
+
55
+ ---
56
+
57
+ ## 二、主观能动性:劲儿、兴趣、惦记
58
+
59
+ ### 她自己攒劲儿
60
+
61
+ 距离上一次自己动过多久、桌上有没有新料,合起来算一个「劲儿」:
62
+
63
+ - 时间底噪:每小时 0.12;
64
+ - 桌上有新料会加快;
65
+ - 满 1.0 才开一轮,动完按 0.8 记账;
66
+ - 两次之间至少 2 小时。
67
+
68
+ 效果是:桌上有东西时更早醒,一直没什么可说时会拖得很久,而不是每天固定几次。
69
+
70
+ ### 兴趣:桌上摆什么
71
+
72
+ 她醒来时,处境里会有一句「桌上摊着这几样东西」或「桌上还有一件没合上的事」,下面最多五条。来源有四个:
73
+
74
+ - **她自己问过、还没答案的问题**(最多 2 条,7 天半衰;重复问同一件事会合并成一条);
75
+ - **搁了很久的旧句子**(7–30 天前说过的,最多 3 条,只在「没有派活的醒来」里摆);
76
+ - **她最近总说到的那件事**(最多 1 条,旁注「最近你总说到它」);
77
+ - **她给你留过话、你有没有来过**(单独一句,72 小时内有效)。
78
+
79
+ 答应过还没做的事(待办)不在这张桌子上面,它固定在处境最前面——她的原话是「摆在我最前面」。
80
+
81
+ 原来「旧句子的桌子」和「好奇的桌子」各占一段,同时出现时提示词里会连着两段「桌上有……」,像两份清单。0.9.0 并成一张桌子、一个总配额,条目之间还会去重。
82
+
83
+ ### 缺工具,她会直接开口要
84
+
85
+ 她想到要做一件事、但手上没有那件工具时,会直接说「我需要一个能读网页的东西,你能给我装上吗」,或者开一张 ask 卡问你——而不是绕着走,或者假装这件事做不到。
86
+
87
+ 插件只给她最基本的一组工具(说话、记录、看 B 站、记梦)。0.9.0 在处境里补了一句:缺工具不是失败,直接说你要什么。主人要不要装、装哪个,仍然是主人的决定;插件不会自己扩工具。
88
+
89
+ ### 这套机制的灵感
90
+
91
+ 「劲儿」和「兴趣」的机制灵感,来自交流群 2 群的群友 **「无」**:主动性不该是定时器,而应该是攒够了的冲动;桌上有什么,决定这一轮有没有由头。落进这个插件时全部重新实现,没有代码或文本的直接搬运。
92
+
93
+ ---
94
+
95
+ ## 三、梦
96
+
97
+ 她偶尔会把真实发生过的事重新拼成一句梦。梦不算成长,也不会被当成新的记忆。
98
+
99
+ 0.9.0 起:
100
+
101
+ - `her_dream` 与 `/dream` 对所有人开放,不再需要 `.dev`;
102
+ - 「她做过的梦」卡搬到**住所页**,和日记、她写的小纸条放在一起——这一页回答的是「东西都在哪」;
103
+ - 开关在**成长页的「梦」卡**里,不在住所页放两份;
104
+ - 关掉只停止记新的,旧的一条不删。
105
+
106
+ 以前这张卡按 `.dev` 藏起来,文案还写着「只在这台机器的开发副本里显示」;现在文案改成「她偶尔会把真实发生过的事重新拼成一句梦;不算成长,也不会被当成新的记忆」。
107
+
108
+ ---
109
+
110
+ ## 四、空醒:醒来没事时,她自己找点事做
111
+
112
+ 醒来时没有待办、桌上也没有东西,她可以自己翻翻旧句子、写点什么,也可以什么都不做。
113
+
114
+ - 开关:`memory.selfWake`,默认 `true`;
115
+ - 设置页在「兴趣」卡里,叫「醒着没事时,自己找点事做」;
116
+ - 关掉只影响这一种醒来,摆料那种照旧。
117
+
118
+ ---
119
+
120
+ ## 五、QQ 桥:开几间房、谁是外客
121
+
122
+ 这一节写给用桥的人;不用桥可以跳过。
123
+
124
+ 桥现在可以自己决定「开几间房、每间是谁」,插件只提供机制。三个可选字段:
125
+
126
+ - `room`:`house`(并进书房)或 `channel`(另开一间)。不传时私聊回书房、群聊另开。
127
+ - `title`:侧栏里那间房叫什么(群名、昵称)。不传才回落到 channel 键。
128
+ - `guest: true`:标记「外面的人」(群友私聊这类)。标了 `guest` 的消息不写 `seen:`、不涨轮数、不触发轮换、不进「上次我们说到这」,正文写「有个不是他的人跟你说话」。
129
+
130
+ `speaker` 现在对所有场次生效:以前只有群聊拼「某某在群里说:」,现在私聊(尤其是群友私聊)也会带上是谁说的,免得三个人被当成一个人。
131
+
132
+ **群房按天轮换。** 默认一间群房用满 **7 天**换新的(`config.rooms.groupRotateDays`,`0` = 不换)。旧房改名成「… YYYY-MM-DD 之前」留在侧栏,新房从当天重新算。热闹的群 200 条上下文一会儿就满,按轮数不好估,所以按天。
133
+
134
+ **房间收纳改成按「最近说话」。** `channels.json` 新增 `bornAt` / `lastAt`,超过 40 间时先丢最久没说话的,不再丢最早建的(旧的「最早建的先被忘掉」会让被丢的群下次说话时再建一间同名房,旧房成孤儿)。老档没有这两个字段时按 `at` 兼容。
135
+
136
+ 常见布局都由桥表达,插件不写死:
137
+
138
+ - 全部并进书房:不传 `channel`,或 `room:'house'`;
139
+ - 每群一间:`channel:'qq-group:<群号>'` + `scene:'group'`;
140
+ - 群友私聊每人一间:`channel:'qq:dm:<QQ号>'` + `scene:'private'` + `room:'channel'` + `guest:true`;
141
+ - 群友私聊进书房但标身份:`scene:'private'` + `guest:true` + `speaker`;
142
+ - 只有 @ 她才进:桥侧过滤,不投递就不建房。
143
+
144
+ ### 40 轮整换房,不再多建一条会话
145
+
146
+ 这是 0.8.8 留下的问题,0.9.0 修掉。
147
+
148
+ 桥接从 QQ 往屋里递话(`POST /dsh-fish/say`)时,满 `chat.rotateAfter` 的那一轮会有两条路同时结账:`lib/say.js` 的投递收尾与 `lib/turns.js` 的轮次收尾,两边都调 `rotateTalkSession`,中间没有互斥——于是 115ms 内各 `sc.create` 一条:先建的收到唤醒、在里面跑了一轮,之后成了侧栏里看不到的孤儿;后建的被写进 `home.json`。长期记忆不丢,但侧栏多一间同名空房,孤儿里那一轮再也翻不到,轮数也被多算。
149
+
150
+ 现在换房间同一时刻只有一次在飞:同一数据目录内合并成一次,跨进程由 `withFileLock` 的 `.talk-session.lock` 兜底;并发进来的第二声直接等第一声的结果,不再建第二条。两条入口再各传一个 `from`,`home.json` 已经指着别的会话时就返回 `skipped`,日志写「换会话跳过」,既不建会话也不写重复的 `rotate:` 记录。
151
+
152
+ 回归:`test/rotate-race.test.mjs` 3 条(同一刻两条入口只建一条、换完后迟到的那次跳过、`from` 不是当前屋子时不换)。
153
+
154
+ ---
155
+
156
+ ## 六、设置页四张卡
157
+
158
+ 设置 → 大肥鱼,现在分成四张独立卡:
159
+
160
+ - **主动搭话**:开 / 关;一天最多几次(0–6);你多久没说话她才可能开口(1–24 小时)。
161
+ - **主观能动性**:她要不要自己攒那股劲。(关掉 = 只按日程叫醒,不会自己开。)
162
+ - **兴趣**:要不要把「最近总说到的那件事」摆到桌上;以及「醒着没事时,自己找点事做」(空醒)。
163
+ - **梦**:梦里记不记事。
164
+
165
+ 默认值:
166
+
167
+ - `proactive.enabled`:**关**;
168
+ - `drive.enabled`、`interest.enabled`、`dream.enabled`、`memory.selfWake`:**开**。
169
+
170
+ 关掉只是以后不做,已经写下的梦、日记、事件一条都不删。
171
+
172
+ ---
173
+
174
+ ## 七、刷 B 站不再挂在叫醒上
175
+
176
+ 醒来时处境里不再有「B 站此刻在推什么」那一大段。`bili_look` / `bili_watch` / `bili_speak` 三个工具仍然可用,她想看的时候可以自己看。
177
+
178
+ 刷站的初衷是「给她一个外部世界的接触面」,但把它挂在唤醒上有两个副作用:一是每次醒来都要先读十几个视频的标题简介热评,上下文开销大;二是它会让「醒来」变成「刷站」,把别的材料挤掉。0.9.0 把它从唤醒路径上摘下来,改成她想看的时候再调工具。
179
+
180
+ 配置上 `browse.enabled` 默认改为 `false`;`bili.enabled` 默认 `true`(工具本身不受影响)。
181
+
182
+ ---
183
+
184
+ ## 八、修掉的 bug
185
+
186
+ ### 劲儿永远是 0.00
187
+
188
+ `drive.js` 里把事件的 `ts` 当毫秒数读(`Number(...)`),而它其实是 ISO 字符串,于是「出生时刻」和「上次自己动过」都被算成 0,日志里一直是 `drive 0.00 · 还没有任何记录,不急着动`——就算她 11 天没动过也一样。改成先按数字、再按日期解析。
189
+
190
+ ### 她永远等不到「安静够久」
191
+
192
+ 安静时长(`minIdleHours`)原本把 `meta seen:` 也算作「他来过」,而这条记录会被**别的会话**的用户活动误写:他在这台机器上跟别的 agent 说话,她的安静时钟就被清零,于是 `proactive skip 刚说过话,还不到时候` 一直挂着,主动搭话一次都开不出来。现在只有真的有话落进她的房间(`heard` 等)才算,记账行一律不算。
193
+
194
+ ### 「没什么要说的」不再算一次开口
195
+
196
+ 真机第一次自动开口,她回的是一整句元话语:「这一轮没什么要说的。今天想说的话,一小时前已经说完了。」她的判断没错,错在我们把这句话记成了一次开口:占掉当天额度、进了对话流、还挡住了静默折叠。
197
+
198
+ 根因是「什么都不做」在文本界面上不成立。现在提示改成明确的出口:不想说就**不要写任何字**,也不要解释为什么不说;同时记账侧兜底——一轮里只有一句这样的元话语时,按静默记、把整轮折成一行,不占额度、不留噪音。判据做得窄(短、且开头就交代「这一轮 / 今天没什么要说的」),真话不会被吞。
199
+
200
+ ### 空桌不醒
201
+
202
+ 劲儿只回答「攒够了没有」,不回答「攒够了有没有事可说」。桌上没有任何材料时,即使劲儿满也不起——否则醒来只能回一句「没什么要说」。
203
+
204
+ ### 群友私聊不再被当成主人
205
+
206
+ 以前私聊不论谁发的都落进书房、写成「他跟你说话了」,群友会被当成主人本人。现在桥给 `channel + room:'channel' + guest:true` 就另开一间;标了 `guest` 的消息不写 `seen:`、不涨轮数、不触发轮换、不进「上次我们说到这」。
207
+
208
+ ### 0.8.9 的两件事(未单独发版,已并入本版)
209
+
210
+ - **递话、叫醒不再堆空白会话。** `lib/deliver.js` 新增 `ensureSessionLive(sc, sessionId)`:先 `resolveAgent()` 探活,探不通才 `create`。`sayToHer`、主动开口、叫醒三处都改走它,会话被别的原因关掉时不会再新建一条空房。
211
+ - **回忆显示的两处修复(社区 PR #26)。** `brief` 档里长聊天的原话保留「|聊天里说的」;改口引用标「他说 / 读到别人说 / 回复」,先截原话再加标记。删掉不再使用的 `UNCLAIMED_PENALTY`,`latestRevisions` 不再导出;没有动 `+4` 认领加分与 voice / heard / answer 的排序。
212
+
213
+ ### 社区 PR #27–#31(已并入本版)
214
+
215
+ - **#27 宿主拼的上下文不算他来过。** 宿主前端的 `snapshot` / `notice` / `catalog` / `instructions` / `recall` 是保留词表(`MACHINE_FORMS`),桥转述人话时不要带 `form`,或者用 `relay`。
216
+ - **#28 假宿主探活时机。** 测试里的探活只发一次,不再因为时机不同而误红。
217
+ - **#29 `her_note` 按她自己写过的话划掉。** 她可以用自己当时那句原话来销掉一张纸条,不必记住内部 id。
218
+ - **#30 没认领的聊天行。** 没被认领的聊天行不再被当成她认领的话。
219
+ - **#31 导出事件流只留元数据。** `her_log` 导出不再带正文(`meta` 行除外)。
220
+
221
+ ---
222
+
223
+ ## 九、从 0.8.x 升上来
224
+
225
+ ### 升级方式
226
+
227
+ 推荐用一条命令(不用先关 DSH):
228
+
229
+ ```bash
230
+ "<DSH>/resources/runtime/cli/bin/dsh.cmd" plugin --profile <profile> add "@ssamurias/dsh-fish@0.9.0"
231
+ ```
232
+
233
+ 也可以走设置页的「检查更新」——但 **0.8.9 从 npm 装的人**要注意:0.8.9 的升级代码只认裸名 `dsh-fish`,带 scope 的安装会被它判成「不归我管 / 开发版」,自动检查更新用不了;而且 0.8.9 的客户端半边还是短 id,设置页可能空着。这批人请直接用上面的命令装 0.9.0。
234
+
235
+ ### 0.9.0 里已经带了原本准备在 0.8.10 发的升级修复
236
+
237
+ - 升级改走 npm,不再把依赖记成 `file:updates/xxx.tgz`;
238
+ - Windows 升级不再弹黑窗口(VBS `wscript //B` 隐藏);
239
+ - 装完先清旧裸名残留(旧依赖键、旧 `bundles` 条目、旧 `node_modules/dsh-fish` 改名收起来),改 `package.json` 前先备份;
240
+ - 客户端注册名跟着包名走,设置页那半边补回来;
241
+ - `link:` 装的用户先跑一次 `node scripts/fix-legacy-link.mjs`,加 `--write` 真改,再启动。
242
+
243
+ ### 配置迁移
244
+
245
+ 1. **`.dev` 文件不再需要**:梦、主动搭话、空醒、桌子 / 好奇 / 兴趣,0.9.0 起对所有人可见。以前为了看这些功能在 `~/.dsh-fish/` 下建的 `.dev` 可以删掉(留着也不影响)。
246
+ 2. **新增配置键**:`memory.selfWake`(空醒开关,默认 `true`)、`drive.enabled`(主观能动性,默认 `true`)、`interest.enabled`(兴趣,默认 `true`)、`dream.enabled`(梦,默认 `true`)、`bili.enabled`(默认 `true`)、`rooms.groupRotateDays`(群房几天换新,默认 `7`,`0` = 不换)。
247
+ 3. **默认关掉了自动刷站**:`browse.enabled` 从 `true` 改为 `false`。想恢复在 `config.json` 里手动设回 `true`。
248
+ 4. **主动搭话默认仍是关的**:`proactive.enabled` 默认 `false`,去设置页打开。老配置升级不会因为这个版本突然变得吵。
249
+ 5. **本机若把 `proactive.minIdleHours` 调得很小**(比如 1),建议调到 3:0.9.0 之前「刚说过话」这个门几乎没生效,调小会显得很吵。
250
+
251
+ ---
252
+
253
+ ## 十、测试与鸣谢
254
+
255
+ 全量 `node --test` = **1441 pass / 0 fail**。零运行时依赖。
256
+
257
+ 感谢 **@BBleae(晴猫)**:从 0.8.6 起的五个 PR——#10 匿名 buvid3、#11 叫醒间隔、#15 叫醒那一轮不记成「他来过」、#23 日报 / 回忆分三种话、#26 回忆 `brief` 档的两处显示。0.9.0 期间还收到 #27–#31 五个 PR(#27 宿主拼的上下文不算他来过、#28 假宿主探活时机、#29 `her_note` 按她自己写过的话划掉、#30 未认领聊天行、#31 导出事件流只留元数据),已全部并入本版。
258
+
259
+ 感谢交流群 2 群的群友 **「无」**:主观能动性的机制灵感来自他讲的「主动性不该是定时器,而是攒够了的冲动」。
260
+
261
+ ---
262
+
263
+ ## 附:这一版没有做的事
264
+
265
+ - 没有加任何新工具。她的工具面仍然是那十只。
266
+ - 没有改人格设置界面。仍然没有「性格参数」。
267
+ - 没有把「主动搭话」默认打开。功能公开可见,默认关着,由用户自己开。
@@ -14,6 +14,9 @@
14
14
  原理页引用了 Kolb / Piaget / Bowlby / Erikson / Festinger / Bartlett / Dewey / Schön 的理论,
15
15
  属思想引用,页底标注了作者与年份;不含他人代码或原文。
16
16
 
17
+ - 主观能动性的攒劲机制灵感来自交流群 2 群的群友「无」;落进本插件时全部重新实现,没有代码或文本的直接搬运。
18
+ - 兴趣与好奇的判据为自研实现(`lib/interest.js`、`lib/curiosity.js`),未移植任何第三方插件代码;概念上参考了 Loewenstein 的信息缺口理论(1994)、Generative Agents 的记忆时间衰减排序(2023)与 Oudeyer 关于内在动机中「新奇会失效」的论述(2007)。
19
+
17
20
  ## 美术资源
18
21
 
19
22
  - `media/fish.jpg`:作者提供的大肥鱼原图经 AI 生成,随本仓库以同一许可(MIT)授权。
package/lib/away.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * 口径(全部只读,不改事件流、不写任何东西):
8
8
  * - 起点 = 你最后一次开口的时间(`meta seen:<ISO>`);从没开过口就从第一条事件算。
9
9
  * - 醒了 N 次(`meta wake:`)、看了 M 条(`browse:` 记账里的 BV 去重)、
10
- * 说了 K 句话(`utterance`)、写了 J 篇日记(`meta diary:`)。
10
+ * 说了 K 句话(`utterance`,不含没认领的聊天行)、写了 J 篇日记(`meta diary:`)。
11
11
  * - 日记只报**篇数**,永不报内容——看不看得到是 `diary.visible` 那件事。
12
12
  */
13
13
  import { readEvents } from './store.js'
@@ -79,7 +79,12 @@ export function summarizeAway(events, { now = Date.now(), maxDays = 60 } = {}) {
79
79
  const t = tsOf(e)
80
80
  if (t && t < floor) continue
81
81
  const type = String(e?.type ?? '')
82
- if (type === 'utterance') { said++; continue }
82
+ // uncommitted 是上次聊天的回复:seen: 在第一句人话时写,它要等回合结束,
83
+ // 所以必然落在「不在」窗口里,不算他不在时她说的。
84
+ if (type === 'utterance') {
85
+ if (!(Array.isArray(e.flags) && e.flags.includes('uncommitted'))) said++
86
+ continue
87
+ }
83
88
  if (type === 'diary') { diaries++; continue }
84
89
  if (type === 'meta') {
85
90
  const txt = String(e.text ?? '')
package/lib/channels.js CHANGED
@@ -36,6 +36,9 @@ const KEY_MAX = 120
36
36
  /** 最多记住多少间房;超了丢最久没说话的(不让映射文件无限长)。 */
37
37
  export const CHANNELS_MAX = 40
38
38
 
39
+ /** 群房按"建了多久"轮换时用的单位。 */
40
+ const DAY_MS = 86_400_000
41
+
39
42
  /** 读-改-写串行化:两条渠道同时投递时别互相覆盖。 */
40
43
  let channelQueue = Promise.resolve()
41
44
 
@@ -64,11 +67,17 @@ export function channelKey(channel) {
64
67
  return s
65
68
  }
66
69
 
67
- /** 房间标题:侧栏里要能一眼看出这是哪间(不出现"他",见 AGENTS.md §4)。 */
68
- export function channelTitle(key, scene = 'group') {
70
+ /**
71
+ * 房间标题:侧栏里要能一眼看出这是哪间(不出现"他",见 AGENTS.md §4)。
72
+ *
73
+ * name 是桥给的显示名(群名 / 昵称)。有名字就用名字,用户才认得出;
74
+ * 没有才退回 channel 键——别把 `qq-group:123456` 当成房间名摆出来。
75
+ */
76
+ export function channelTitle(key, scene = 'group', name = '') {
69
77
  const label = scene === 'group' ? '群聊' : '私聊'
70
78
  const short = key.length > 28 ? `${key.slice(0, 28)}…` : key
71
- return `跟大肥鱼说话·${label}(${short})`
79
+ const who = String(name ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, 40)
80
+ return `跟大肥鱼说话·${label}(${who || short})`
72
81
  }
73
82
 
74
83
  /** 读出全部映射(坏文件当空表,不抛)。 */
@@ -86,6 +95,10 @@ export async function readChannels(root) {
86
95
  sessionId: typeof r.sessionId === 'string' ? r.sessionId.trim() : '',
87
96
  title: typeof r.title === 'string' ? r.title : '',
88
97
  at: typeof r.at === 'string' ? r.at : '',
98
+ // bornAt 管轮换(这间房用了多久),lastAt 管收纳(多久没说话)。
99
+ // 老档没有这两个字段:at 是唯一可信的时间,就都拿它兜底。
100
+ bornAt: typeof r.bornAt === 'string' && r.bornAt ? r.bornAt : (typeof r.at === 'string' ? r.at : ''),
101
+ lastAt: typeof r.lastAt === 'string' && r.lastAt ? r.lastAt : (typeof r.at === 'string' ? r.at : ''),
89
102
  }))
90
103
  }
91
104
 
@@ -103,7 +116,7 @@ export async function findChannel(root, channel) {
103
116
  * 同一个 key 再写一次就是更新(换过房、改过名都走这里),
104
117
  * 所以桥那边不需要知道"要不要登记"——投递一次就够了。
105
118
  */
106
- export async function saveChannel(root, { channel, scene = 'group', sessionId, title = '' }) {
119
+ export async function saveChannel(root, { channel, scene = 'group', sessionId, title = '', bornAt = '' }) {
107
120
  const key = channelKey(channel)
108
121
  if (!key) return { ok: false, why: 'channel 是空的' }
109
122
  const sid = String(sessionId ?? '').trim()
@@ -111,17 +124,23 @@ export async function saveChannel(root, { channel, scene = 'group', sessionId, t
111
124
  return mutate(async () => {
112
125
  const list = await readChannels(root)
113
126
  const now = new Date().toISOString()
127
+ const prev = list.find((r) => r.key === key)
114
128
  const entry = {
115
129
  key,
116
130
  scene: normalizeScene(scene),
117
131
  sessionId: sid,
118
132
  title: String(title || channelTitle(key, normalizeScene(scene))),
119
133
  at: now,
134
+ // 同一间房再写一次(群消息复用房间)时保留本来建的时间;
135
+ // 只有真建了新会话才重新起算,否则轮换永远等不到。
136
+ bornAt: String(bornAt || prev?.bornAt || now),
137
+ lastAt: now,
120
138
  }
121
139
  const rest = list.filter((r) => r.key !== key)
122
140
  rest.push(entry)
141
+ // 收纳按「最久没说话」丢,不按「建得最早」丢(见 AGENTS §7.2)。
123
142
  const trimmed = rest
124
- .sort((a, b) => String(a.at).localeCompare(String(b.at)))
143
+ .sort((a, b) => String(a.lastAt || a.at).localeCompare(String(b.lastAt || b.at)))
125
144
  .slice(-CHANNELS_MAX)
126
145
  try {
127
146
  await writeAtomic(channelsPath(root), JSON.stringify({ version: 1, list: trimmed }, null, 2))
@@ -132,6 +151,21 @@ export async function saveChannel(root, { channel, scene = 'group', sessionId, t
132
151
  })
133
152
  }
134
153
 
154
+ /**
155
+ * 这间房是不是用满了天数(群房按天轮换,不看轮数)。
156
+ *
157
+ * 为什么不用轮数:群和群的热度差几十倍,同一阈值要么小群永远换不了,
158
+ * 要么热闹的群一天换三间。按「建了多久」更稳,也更好解释。
159
+ * days <= 0 表示不轮换(用户关掉)。
160
+ */
161
+ export function channelExpired(entry, days, now = Date.now()) {
162
+ const d = Number(days)
163
+ if (!entry || !(d > 0)) return false
164
+ const born = Date.parse(String(entry.bornAt || entry.at || ''))
165
+ if (!Number.isFinite(born)) return false
166
+ return now - born >= d * DAY_MS
167
+ }
168
+
135
169
  /** 某间房被用户收起来了(会话归档):从映射里去掉,下次投递会重新建一间。 */
136
170
  export async function dropChannel(root, channel) {
137
171
  const key = channelKey(channel)
@@ -159,17 +193,23 @@ export async function dropChannel(root, channel) {
159
193
  * "同屋还有别的活会话"这些宿主细节,那套逻辑已经在 home.js 里了。
160
194
  * 这里只做**纯判断 + 查表**,好测、好读、不碰宿主。
161
195
  */
162
- export async function resolveChannel(root, { channel = '', scene = '' } = {}) {
196
+ export async function resolveChannel(root, { channel = '', scene = '', room = '' } = {}) {
163
197
  const key = channelKey(channel)
164
198
  const want = normalizeScene(scene)
165
- if (!key) return { house: true, key: '', scene: want, sessionId: '', known: false }
166
- if (want !== 'group') {
167
- // private:桥过来的私聊仍进书房,这是"直接接进来"的那个选项。
168
- return { house: true, key, scene: 'private', sessionId: '', known: false }
199
+ const wantRoom = String(room ?? '').trim().toLowerCase()
200
+ if (!key) return { house: true, key: '', scene: want, sessionId: '', known: false, room: 'house' }
201
+ // room 决定"这间算不算一间房":
202
+ // channel → 一定另开(群友私聊每人一间、按人分房都走这条)
203
+ // house → 一定回书房(所有群合并成一条也走这条)
204
+ // 不传 → 老行为:群聊开房,私聊回书房
205
+ const toChannel = wantRoom === 'channel' || (wantRoom !== 'house' && want === 'group')
206
+ if (!toChannel) {
207
+ // 合并回书房时仍保留原场次:群消息不该因为"没单独开房"就算他来过。
208
+ return { house: true, key, scene: want === 'group' ? 'group' : 'private', sessionId: '', known: false, room: 'house' }
169
209
  }
170
210
  const hit = await findChannel(root, key)
171
- if (hit?.sessionId && hit.scene === 'group') {
172
- return { house: false, key, scene: 'group', sessionId: hit.sessionId, known: true }
211
+ if (hit?.sessionId) {
212
+ return { house: false, key, scene: want, sessionId: hit.sessionId, known: true, room: 'channel', entry: hit }
173
213
  }
174
- return { house: false, key, scene: 'group', sessionId: '', known: false }
214
+ return { house: false, key, scene: want, sessionId: '', known: false, room: 'channel' }
175
215
  }