@ssamurias/dsh-fish 0.8.9 → 0.9.1

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 (60) hide show
  1. package/AGENTS.md +64 -10
  2. package/CHANGELOG.md +129 -2
  3. package/LICENSE +7 -0
  4. package/README.md +69 -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/RELEASE-NOTES-0.9.1.md +296 -0
  16. package/RELEASE.md +151 -0
  17. package/THIRD-PARTY-NOTICES.md +3 -0
  18. package/lib/ask.js +18 -5
  19. package/lib/away.js +7 -2
  20. package/lib/browse.js +1 -0
  21. package/lib/capture.js +16 -1
  22. package/lib/channels.js +53 -13
  23. package/lib/client.js +145 -56
  24. package/lib/comment.js +74 -0
  25. package/lib/compact.js +71 -2
  26. package/lib/curiosity.js +6 -2
  27. package/lib/derive.js +21 -8
  28. package/lib/desk.js +4 -2
  29. package/lib/dream.js +3 -0
  30. package/lib/dreamtable.js +28 -2
  31. package/lib/drive.js +58 -9
  32. package/lib/freewake.js +21 -2
  33. package/lib/home.js +141 -24
  34. package/lib/inbox.js +7 -2
  35. package/lib/index.js +8 -12
  36. package/lib/interest.js +26 -4
  37. package/lib/log.js +22 -1
  38. package/lib/look.js +25 -5
  39. package/lib/note.js +35 -13
  40. package/lib/notify.js +2 -0
  41. package/lib/observe.js +22 -7
  42. package/lib/outing.js +2 -0
  43. package/lib/owner.js +5 -2
  44. package/lib/persona.js +18 -3
  45. package/lib/proactive.js +78 -18
  46. package/lib/say.js +104 -58
  47. package/lib/scheduler.js +162 -60
  48. package/lib/selfcheck.js +2 -3
  49. package/lib/selfdoc.js +8 -9
  50. package/lib/selfupdate.js +183 -41
  51. package/lib/settings.js +33 -2
  52. package/lib/status.js +31 -3
  53. package/lib/store.js +14 -2
  54. package/lib/tenant.js +12 -0
  55. package/lib/turns.js +46 -4
  56. package/lib/update.js +14 -0
  57. package/lib/upgrade.js +23 -4
  58. package/lib/voices.js +2 -0
  59. package/package.json +5 -2
  60. package/scripts/fix-legacy-link.mjs +254 -0
package/RELEASE.md ADDED
@@ -0,0 +1,151 @@
1
+ # 发版流程(dsh-fish)
2
+
3
+ > 适用 0.9.1 之后。本仓库的规矩是**先改在本地、后发布**:本地 `main` 改完、测试全绿,等维护者明确说「发」再执行第 3 步起的命令。没点头就只做到第 2 步。
4
+ >
5
+ > 本机环境:Windows + Git Bash;仓库在 `D:/deepseekagent/02-我的项目/大肥鱼/plugin`;`gh` 在 `/c/Users/DANG/bin/gh`(登录名 `ssamurias`);npm 账号同名。
6
+
7
+ ---
8
+
9
+ ## 0. 发布前先确认三件事
10
+
11
+ ```bash
12
+ cd "D:/deepseekagent/02-我的项目/大肥鱼/plugin"
13
+ git status --short # 必须是空的
14
+ git log --oneline origin/main..HEAD # 看看这次要发几个提交
15
+ npm whoami --registry=https://registry.npmjs.org/ # 必须是 ssamurias;报 E401 就先 npm login(见第 4 步)
16
+ gh auth status # 必须是 ssamurias
17
+ ```
18
+
19
+ - 发布本身**不碰本地 profile**,不用关 DSH Desktop。
20
+ - 只有「发完在本地装一遍验证」时才要先关应用——运行中替换已加载的包会 `ERR_PNPM_EPERM`(避坑指南第 3 节)。
21
+ - 本机 npm 账号开了 2FA 的话,`npm publish` 会要 6 位验证码,见第 4 步。
22
+
23
+ ---
24
+
25
+ ## 1. 发布前自检(每次都要跑,别跳)
26
+
27
+ - [ ] `package.json` 的 `version` 就是要发的号(0.9.x 阶段只发 patch)
28
+ - [ ] `node --test` 全绿(0.9.1 基线:**1512 pass / 0 fail**,约 22 秒)
29
+ - [ ] `CHANGELOG.md` 顶部有本节,条目按 issue 编号写全
30
+ - [ ] `RELEASE-NOTES-x.y.z.md` 已写,且与 `CHANGELOG.md` 口径一致(issue 条数、PR 编号、日期)
31
+ - [ ] `README.md` 顶部「当前版本」段已换成这一版
32
+ - [ ] 升级路径有变化时,`README.md`「从旧版升到 0.9.x」表补了行,`AGENTS.md`「包名与装法」补了守门条目
33
+ - [ ] `npm pack --dry-run` 的文件清单对:`lib/`、`media/`、`scripts/`、`cordis.patch.yml`、`README.md`、`AGENTS.md`、`CHANGELOG.md`、`RELEASE-NOTES-*.md`、`RELEASE.md`;**不含 `test/`**
34
+ - [ ] 包里没有敏感文件(`.env`、profile、cookie、备份档)
35
+
36
+ ```bash
37
+ node --test
38
+ npm pack --dry-run
39
+ ```
40
+
41
+ `npm pack --dry-run` 只打印清单、不真打包;看完记得确认没有 `test/`、没有 `~/.dsh-fish` 相关的东西。
42
+
43
+ ---
44
+
45
+ ## 2. 本地提交(不 push)
46
+
47
+ ```bash
48
+ git add -A
49
+ git commit -m "0.9.x:<一句话说清这一版>"
50
+ ```
51
+
52
+ - 0.9.1 就是这么发出去的:先在本地攒了 10 个提交(含四个社区 PR 的合并提交),维护者说「发」之后才推。之后同样先攒、别边改边推。
53
+ - 没点头就停在这里。下面每一步都会动线上(GitHub / npm),发布出去基本收不回。
54
+
55
+ ---
56
+
57
+ ## 3. 推代码上 GitHub
58
+
59
+ ```bash
60
+ git push origin main
61
+ ```
62
+
63
+ - 若 `github.com:443` 不通(本机常见),改用 Git Data API 推:`node D:/deepseekagent/04-小工具/fish-push-generic.mjs`(逐提交建树、建 commit,并断言树 SHA 与本地一致),成功后 `node D:/deepseekagent/04-小工具/fish-realign-shas.mjs`(按 `.git/push-map.json` 把本地 SHA 对齐成服务端版本,同时更新 `origin/main`)。原理与三个坑见避坑指南第 27 节。
64
+ - **不要** `git push --force` / `git push --delete`:会被 CC Safety Net 拦下。确需删远端引用,用 `gh api -X DELETE /repos/ssamurias/dsh-fish/git/refs/<ref>`。
65
+ - 推完去 Actions 看一眼三档 Node(20 / 22 / 24)是否全绿:`gh run list --limit 3`。
66
+
67
+ ---
68
+
69
+ ## 4. 发 npm 包(需要你手动跑,可整段复制)
70
+
71
+ > 这一段是给维护者本人执行的:登录和 2FA 都在你的终端里完成,agent 不代跑发布。
72
+ > **本机 npm 默认 registry 是 `https://registry.npmmirror.com`,所以每条命令都必须显式带 `--registry=https://registry.npmjs.org/`**,否则会得到看似「包不存在 / 没权限」的 E404。
73
+
74
+ ```bash
75
+ cd "D:/deepseekagent/02-我的项目/大肥鱼/plugin"
76
+
77
+ # 0) 确认身份:返回 ssamurias 就说明本机已经登录,可以跳过第 1 步
78
+ npm whoami --registry=https://registry.npmjs.org/
79
+
80
+ # 1) 只有第 0 步报 E401 / 不是 ssamurias 时才需要:浏览器登录
81
+ npm login --registry=https://registry.npmjs.org/
82
+ npm whoami --registry=https://registry.npmjs.org/
83
+
84
+ # 2) 发布(开了 2FA 就在末尾加 --otp=123456)
85
+ npm publish --registry=https://registry.npmjs.org/
86
+
87
+ # 3) 核对:应为 0.9.x 和 latest -> 0.9.x
88
+ npm view @ssamurias/dsh-fish version dist-tags --registry=https://registry.npmjs.org/
89
+ ```
90
+
91
+ - **不想每条都带参数**:`npm config set registry https://registry.npmjs.org/`,发完再改回 `npm config set registry https://registry.npmmirror.com/`。
92
+ - **scoped 包没有写权限时 npm 故意返回 404(不是 403)**:看到 E404 先查 `npm whoami`,别怀疑包名或网络。
93
+ - 本包已经是公开包,**不需要** `--access public`(只有首次发布才要)。
94
+ - 常见报错:
95
+ - `E401`:没登录 / token 过期 → `npm logout --registry=...` 再 `npm login --registry=...`
96
+ - `E403`:账号没有 `@ssamurias` scope 写权限,或开了 2FA 没给 `--otp`
97
+ - `EPUBLISHCONFLICT`:这个版本号已经发过 → 已发布版本不能覆盖,升 patch 号重来
98
+ - `npm publish --dry-run` 可以先看会打进去哪些文件,不会真的发。
99
+ - 用户端注意:pnpm 的 `minimumReleaseAge`(默认 24 小时)会把「太新」的版本回滚掉;本机 profile 已设 `minimumReleaseAge: 0`(避坑指南第 3 节)。
100
+
101
+ ## 5. 打 tag + 建 GitHub Release
102
+
103
+ **npm 发布成功之后**再做这一步,npm 失败就停住、别打 tag(免得留一个指向没发出去的版本的悬空 tag)。
104
+
105
+ ```bash
106
+ git tag -a v0.9.x -m "0.9.x"
107
+ git push origin v0.9.x
108
+ export PATH="/c/Users/DANG/bin:$PATH"
109
+ gh release create v0.9.x -F RELEASE-NOTES-0.9.x.md --title "0.9.x"
110
+ ```
111
+
112
+ - Release 正文直接用仓库里的 `RELEASE-NOTES-*.md`(`-F` 读文件),不要在网页上手打——两边会不一致。
113
+ - 想先存草稿:`gh release create v0.9.x -F RELEASE-NOTES-0.9.x.md --draft`,检查完再 `gh release edit v0.9.x --draft=false`。
114
+ - `git push origin v0.9.x` 若也卡在 443:用 `gh api -X POST repos/ssamurias/dsh-fish/git/tags` 在服务端建 tag 对象,再 `gh api -X POST repos/ssamurias/dsh-fish/git/refs -f ref=refs/tags/v0.9.x -f sha=<tag对象sha>`。
115
+ - 兜底也可以在网页上建 Release(会自动建 tag),但正文仍以 `RELEASE-NOTES-*.md` 为准,别手打。
116
+
117
+ ---
118
+
119
+ ## 6. 发布后验证
120
+
121
+ ```bash
122
+ npm view @ssamurias/dsh-fish version dist-tags
123
+ npm view @ssamurias/dsh-fish@0.9.x dist.tarball
124
+ gh release view v0.9.x
125
+ ```
126
+
127
+ - `dist-tags.latest` 要指向新版本;`dist.tarball` 能取到 URL 就说明包在。
128
+ - 想真装一遍:**先关 DSH Desktop**,用临时 profile 试装(不要直接动 `desktop` profile)——`bundles` 与 `dependencies` 必须一一对应,先只改依赖、装包,再用目录联接的临时 profile 跑 `--dump-config` 预演(避坑指南第 1 节)。
129
+ - 回 issue:#32–#38、#41–#43 各回一条「0.9.1 已发布,装法见 README」,等作者确认后再关。
130
+ - 给四个社区 PR 的作者(@BBleae 等)留一句「已随 0.9.1 发布」。
131
+
132
+ ---
133
+
134
+ ## 7. 出问题时怎么回滚
135
+
136
+ | 对象 | 命令 | 说明 |
137
+ | --- | --- | --- |
138
+ | npm 版本 | `npm unpublish @ssamurias/dsh-fish@0.9.x` | 72 小时内可撤;超过 72 小时改用 `npm deprecate @ssamurias/dsh-fish@0.9.x "原因,请装 x.y.z"`,不要再发同名版本 |
139
+ | git tag | `git tag -d v0.9.x` 然后 `git push --delete origin v0.9.x` | `--delete` 被拦时用 `gh api -X DELETE /repos/ssamurias/dsh-fish/git/refs/tags/v0.9.x` |
140
+ | GitHub Release | `gh release delete v0.9.x` | 加 `--cleanup-tag` 连 tag 一起删 |
141
+ | 代码 | 用 `git revert <提交>` 再正常 push | **不要** force push `main` |
142
+
143
+ ---
144
+
145
+ ## 8. 这个项目特有的坑(发布前扫一眼)
146
+
147
+ - **包名 / 迁移脚本别动**:`@ssamurias/dsh-fish`、`cordis.patch.yml` 的 `id: dsh-fish`、`lib/selfupdate.js` 的 `normalizeScript()` 三条都在 `AGENTS.md` 的「包名与装法」,发布前改它们等于给自己造升级事故。
148
+ - **升级说明要和脚本一致**:`scripts/fix-legacy-link.mjs` 现在认三种依赖写法(`link:` / `file:` tgz / 版本号)。改了它,`README.md` 升级表与 `RELEASE-NOTES-*.md` 必须同步改,否则用户照文档做还是会崩。
149
+ - **`desktop` profile 被应用独占**:`dsh plugin --profile desktop ...` 会被拒,验证要用别的 profile。
150
+ - **别在应用运行时替换已加载的包**:`ERR_PNPM_EPERM`,包目录会被留成「已删除」。
151
+ - 备份照旧放 `D:/deepseekagent/dsh-fix-backup/`,不要放进仓库。
@@ -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/ask.js CHANGED
@@ -32,7 +32,7 @@ import { loadConfig, updateConfig, dateKey } from './settings.js'
32
32
  import { deriveSelf } from './derive.js'
33
33
  import { defineTool } from './tool.js'
34
34
  import { callerOf } from './tenant.js'
35
- import { askInChat } from './askinchat.js'
35
+ import { askInChat, picked } from './askinchat.js'
36
36
  import { tsMs } from './schema.js'
37
37
  import { logLine } from './log.js'
38
38
 
@@ -272,10 +272,23 @@ async function askInCard(ctx, { sessionId, ev, options, signal }) {
272
272
  })
273
273
  if (!r.ok) return { state: 'unanswered', note: '问了,还没答。', said: '', code: r.code ?? 'NO_ANSWER', raw: '' }
274
274
  const pid = `ask-${ev.id}`
275
- const one = (Array.isArray(r.answers) ? r.answers : []).find((a) => a && a.id === pid)
276
- const sel = Array.isArray(one?.selected) ? one.selected[0] : one?.custom
275
+ // issue #35:他直接在卡片里打字回答时,宿主回的是 `{id, selected: [], custom: '…'}`
276
+ // (dsh-user-questions 的约定:custom 覆盖选项、selected 留空)。以前这里只看
277
+ // `Array.isArray(selected)`,空数组当成了「选了第一项里的 undefined」,于是
278
+ // 手打的答案永远认不出来、一律记成 NO_MATCH。`askinchat.js` 的 picked() 早就
279
+ // 按这个约定写对了(非空 selected 优先,否则退回 custom),这里复用它。
280
+ const sel = picked(r.answers, pid)
277
281
  const idx = askChoiceIndex(options, sel)
278
- const raw = JSON.stringify(r.answers ?? []).slice(0, 300)
282
+ // issue #35 后半:这句 raw 会被 logLine 写进日志,以前是整段回执原文——
283
+ // 他手打的自由回答(可能是私事)就这样落进了日志文件。现在只留形状:
284
+ // 每条回执的 id、选了几个、自定义文本多长。排障够用,原文不落地。
285
+ const raw = JSON.stringify(
286
+ (Array.isArray(r.answers) ? r.answers : []).map((a) => ({
287
+ id: a?.id,
288
+ selected: Array.isArray(a?.selected) ? a.selected.length : '-',
289
+ custom: typeof a?.custom === 'string' ? a.custom.length : 0,
290
+ })),
291
+ ).slice(0, 300)
279
292
  if (idx === 0) return { state: 'approved', note: '可以。', said: String(options[0] ?? ''), raw }
280
293
  if (idx === 1) return { state: 'rejected', note: '这次不行。', said: String(options[1] ?? ''), raw }
281
294
  if (idx === 2) return { state: 'later', note: '以后再说。', said: String(options[2] ?? ''), raw }
@@ -399,7 +412,7 @@ export function registerAsk(ctx, deps = {}) {
399
412
  // 2026-10-30 真机(用户 m01756):卡片消失了,事件流里却没有 answer 行,
400
413
  // 回执只写「已问」——事后完全看不出是「没答」还是「认不出选择」。
401
414
  // 这里把 code 和原始回执留在日志里,下次照日志修,不再猜。
402
- logLine('warn', ['她问了却没结算:', r.code || 'NO_MATCH', '·', r.raw || '没有回执', '· ask=', ev.id], root)
415
+ logLine('warn', ['她问了却没结算:', r.code || 'NO_MATCH', '·', r.raw ? `回执形状 ${r.raw}` : '没有回执', '· ask=', ev.id], root)
403
416
  return { ok: true, note: '问了,等他点头。', said: '', askId: ev.id }
404
417
  }
405
418
  const verdict = r.state === 'approved' ? 'approve' : r.state === 'rejected' ? 'reject' : 'later'
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/browse.js CHANGED
@@ -540,6 +540,7 @@ export const LOOK_BRIEF = [
540
540
  ' what=search 搜一个词(要带 keyword)',
541
541
  ' what=related 看某个视频之后,平台又接着推什么(要带 bvid)',
542
542
  ' what=video 看指定的那一条(要带 bvid:BV 号,或者他递过来的链接)',
543
+ ' what=replies 看你自己发过的评论下面有没有人回你(可带 bvid 只看那一条)',
543
544
  '它只读,不会留下任何痕迹,也不会让别人知道你在看。',
544
545
  '它能给你标题、简介、标签、热评、弹幕——给不了视频画面。',
545
546
  '和 growth_commit 一样,你不用它也可以。',
package/lib/capture.js CHANGED
@@ -23,6 +23,7 @@ import { inWake } from './wakewin.js'
23
23
  import { refreshSelfDoc } from './selfdoc.js'
24
24
  import { guardWrite } from './tenant.js'
25
25
  import { activeOf, activePossessionOf, readOutings, readPossessions } from './outing.js'
26
+ import { readChannels } from './channels.js'
26
27
  import { tripKey } from './keepsake.js'
27
28
 
28
29
  /**
@@ -206,7 +207,21 @@ export function registerCapture(ctx, deps = {}) {
206
207
  // 坑(p1.test.mjs 抓到的):这里的 source 必须是 'system'。
207
208
  // 写成 'tool' 时 schema 会以 NOT_HER_TYPE 拒掉 meta,而外面套着 .catch(()=>{}),
208
209
  // 于是 seen 永远写不进去 → 冷落检测永远返回 no-contact-yet → 整条链路静默失效。
209
- if (!inWake()) {
210
+ //
211
+ // issue #34:这条判据以前只看「在不在叫醒窗口里」,不看这句话是在哪说的。
212
+ // 群聊那轮她调一次 growth_commit,就给群里多记一条 `seen:` —— 群友说一百句
213
+ // 也算「他来过」,冷落检测于是永远不触发(他一周没露面,面板还显示今天聊过)。
214
+ // AGENTS.md §7.2 第 2 条本来就要求「外客不动生命体征」,这里补上:
215
+ // 这条会话如果是群房,就当他在场这件事没发生过。
216
+ //
217
+ // 为什么只跳群房(2026-11-05 用户确认):另外两处「窗口外的自己醒」——
218
+ // 换房前问她上一间还有没有话(home.js:753)、定时任务与 goal——
219
+ // 一个通常是在应他上一轮的话,一个分不出是不是在回他,所以照记。
220
+ // 要再收紧得先给每种叫醒打标记,不在 #34 这一刀里做。
221
+ const outsideWake = !inWake()
222
+ const inGroupRoom = outsideWake && (await readChannels(root).catch(() => []))
223
+ .some((r) => who.sessionId && r.sessionId === who.sessionId && r.scene === 'group')
224
+ if (outsideWake && !inGroupRoom) {
210
225
  await appendEvent(
211
226
  { type: 'meta', text: `seen:${new Date().toISOString()}` },
212
227
  { day: model.day, stage: model.stage, source: 'system' },
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
  }