@ssamurias/dsh-fish 0.9.1 → 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.
package/AGENTS.md CHANGED
@@ -69,7 +69,7 @@
69
69
  node --test
70
70
  ```
71
71
 
72
- 当前基线 **1512 个**,独立进程跑 `node --test` 应全绿。任何一条红都不许提交——包括看起来"不是我弄的"那条。
72
+ 当前基线 **1609 个**,独立进程跑 `node --test` 应全绿。任何一条红都不许提交——包括看起来"不是我弄的"那条。
73
73
 
74
74
  按环境挑跑法:
75
75
 
@@ -161,6 +161,31 @@ ctx.on('dsh-fish/busy', (sessionId) => stillPlaying(sessionId) ? true : undefine
161
161
 
162
162
  定时叫醒和主动回合投递前都会问一声。监听要同步返回,只有 `true` 算忙;不忙返回 `undefined`、`null` 或 `false`,返回别的值会挡住后面的插件,返回 Promise 按不忙算。忙的标记卡住时她就一直不被叫醒,所以结束时记得放开。不挂也没事,调度器自己知道家里那条会话正在跑的那一轮。
163
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
+
164
189
  ### 7.4 另一条老路:工具白名单(仍然有效)
165
190
 
166
191
  桥如果是以插件形式装进来、暴露工具给她的,仍然可以在 `config.tools.allow`(设置页「插件能力白名单」)里放行工具名。qq-bridge 默认要放行的是:
@@ -189,7 +214,7 @@ qq_send_message, qq_get_unread_messages, qq_reply
189
214
  - [ ] 三个依赖字段还是 `{}`
190
215
  - [ ] 没有新增人格设置入口
191
216
  - [ ] 新输入口都过了 `guard.js`
192
- - [ ] `node --test` 1512 全绿
217
+ - [ ] `node --test` 1609 全绿
193
218
  - [ ] 界面文案没有「他」「四道门」「三道门」
194
219
  - [ ] 改了 `EVENT_SCHEMA_VERSION` 的话,`CHANGELOG.md` 里写了迁移说明
195
220
  - [ ] 没动包名;真要动,先看下面「包名与装法」那节
package/CHANGELOG.md CHANGED
@@ -1,3 +1,42 @@
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
+
1
40
  ## 0.9.1 — 2026-11-06
2
41
 
3
42
  社区报的 issue #32–#38、#41–#43(十条,@BBleae)加用户直接反馈的几条(换房问话每轮回弹、B 站评论看不到、艾特不出来、cookie 观感),另外合入四个社区 PR(#39、#40、#44、#45)。每一条都在本地复现过,都是我们这边的错,逐条修掉。
package/README.md CHANGED
@@ -12,7 +12,9 @@
12
12
 
13
13
  📺 **[看视频讲解(19 分钟)](https://www.bilibili.com/video/BV1oTnZ6LEtc/)** —— 这个插件是怎么想出来的、她为什么会长成那样、13 亿 token 都花在哪了。
14
14
 
15
- > **当前版本 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)**。
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)**。
16
18
  >
17
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)**。
18
20
  >
@@ -371,7 +373,7 @@ lib/
371
373
  toolschema.js 简写参数 DSL → 标准 JSON Schema
372
374
  observe.js 宿主侧 JSON API(/dsh-fish/state、/events、/beliefs、/diary、/config、/wake、/browse、/reports、/update)
373
375
  client.js 浏览器半边:设置 → 大肥鱼
374
- test/ 1512 个测试,node --test 全绿
376
+ test/ 1609 个测试,node --test 全绿
375
377
  ```
376
378
 
377
379
  ## 跑测试
@@ -0,0 +1,46 @@
1
+ # 0.9.2 更新说明
2
+
3
+ 2026-11-06。这一版也**没有新功能**:修的是「她该说话时没说话、说了却没被看见、记下了却没上桌」这一类故障。不改配置键、不改事件字段、不升事件格式版本——装上去旧数据照读。
4
+
5
+ 修的是社区报的 issue #51–#53、五个社区 PR(#46–#50,@BBleae),加三处用户直接反馈的问题(保存键找不着、便条写满、回复每轮重复投递)。都在本地复现过。
6
+
7
+ ## 你会看到什么
8
+
9
+ - **她不会突然连着几轮只说「已完成分析」、一个字不写了。** 叫醒时那句「这一轮是你自己的,不想说就什么都别写」以前是整进程共用一份,会串到别的会话;现在只影响被叫醒的那条会话,并且最多活 10 分钟,你一开口就清掉。
10
+ - **外客通路(QQ 桥、群聊、别的插件)问一句,`herReply` 里能拿到她真正回的那句话了。** 以前她只写正文、没调 `growth_commit` 时,接口会回「她这次没说话」。
11
+ - **面板不再把「到点了但没东西可摆」写成「醒了」。** quiet 改成「到点了,暂时没有可出的事件」,时间线里没有 wake 的块也不再补一句「醒了」。
12
+ - **插件停用后后台真的停了。** 以前停用后定时器还在跑,再启用会新旧两个调度器一起叫她(#48)。
13
+ - **叫醒一直失败时,不再每分钟重试、每分钟往事件流写一行。** 同一天最多 3 次,先隔 5 分钟、再隔 45 分钟;手动点的那次不占额度(#50)。
14
+ - **他回来过一次之后,冷落检测还能再触发。** 以前回来一次就永久判「这次冷落结束了」(#49)。系统时间回拨也不会再把天数算没。
15
+ - **配了发送工具的部署,日志不再把「只写正文、没发出去」说成「没什么要说的」**(#47)。
16
+ - **卡片可以被别的插件接走,回答隔很久也能结算。** 新增 `dsh-fish/card` / `dsh-fish/answer` 两个事件;对不上选项、没选都不结算,id 留着可重试(#46)。
17
+ - **第 3 天的卡池不再空着**:stage 1 补了一张 `notice_one`,第 1 天仍让给 `first_bet`;这张卡每次换一种问法。
18
+ - **扫码页签下也能点「保存」「测试」了。** 以前默认停在「手机扫码」时,两个按钮都不在页签里(#52)。
19
+ - **200 张便条都划掉之后,还能再记新的。** 名额改成只数没划掉的;`list` 能按状态翻页、`drop` 要写原因、`update` 能改正文(#53)。
20
+ - **系统时间被改到未来之后,冷落会一直挂着。** 现在按追加顺序判断「回来过」,不再被一条未来的 `ts` 卡住(#49 补)。
21
+ - **她传越界或认不出的值时,入口就会拒。** `confidence` 不再是写盘时悄悄夹到边上、`verdict` 不再悄悄落空(自查)。
22
+ - **主动性不再绕开「每天几点醒」。** 以前主动回合排在窗口判断之前,窗口外也可能被她主动找;现在同一道门一并管住。真想「随时可以来找」,等以后做独立开关。
23
+ - **时段框下面多了一行 24 小时制。** 原生时间框跟随系统 12 小时制,`00:00` 会显示成「上午 12:00」,很容易读成中午;现在补一行 `24 小时制:00:00 → 23:00`(跨零点标「次日」)。
24
+ - **换了条对话、她没接上那一轮时,不再当没事。** 叫醒只被收进收件箱、没真跑出东西的时候,以前插件以为成了,现在按「会话仍是空白」再叫一次(同一回合只复核一次);建会话的退路也改了:已经跑过 turn 的会话不再拿 `create` 硬开,免得宿主按 cwd 静默另开一条空壳。
25
+ - **她不想回的那句话,不会再每轮都被递一遍。** 以前只有她真回了才记「提醒过」,于是「好的,阿留」这种话每轮扫描、每次叫醒都原样再摆一次;现在摆到她眼前就算提醒过(一次最多 5 条,剩下的下轮再来),她自己主动去看仍然看得见。
26
+ - **只开着一个界面、后台还挂着一个副本时,那个副本不再往她记忆里写「你来过」了。** 同一份数据目录被两个进程打开,后到的那个本来就该只读(她写东西会被拒);但「记下你来过」这一行绕过了那道门,照样进事件流——结果「你有多久没出现」在她眼里永远不准。现在只读的副本一律不记、不涨轮;占用权中途被另一个进程拿走,也立刻停。
27
+ - **同一句「你来过」不会再记两遍。** 插件被重复加载时,同一个进程里挂上了两个观察者,同一句话写出两条连毫秒都一样的记录(日志里见过同一秒三条)。现在同一进程只挂一个。
28
+
29
+ ## 迁移
30
+
31
+ **无必改项。** 不改事件字段、不升 `EVENT_SCHEMA_VERSION`,旧数据照读。
32
+
33
+ 从 0.9.0 用 `plugin add` 升上来的,0.9.1 已经认得版本号依赖,这一版不用再跑 `fix-legacy-link.mjs`。
34
+
35
+ 一处观感变化:`/dsh-fish/say` 返回的 `said` 里可能出现 `uncommitted:true` 的条目(这一轮她在会话里说的正文,只为回话带回,不落盘)。外部接入如果按 `said.length` 数「她写了几条」,要按这个字段分开看。
36
+
37
+ ## 测试
38
+
39
+ 全量 `node --test` = **1609 pass / 0 fail**(0.9.1 是 1512,本版新增 97 条)。
40
+
41
+ 新增集中在:#46 卡片契约与四种结算路径(45 条)、#48 停用与重新启用、#49 回来之后再冷落、#50 失败退避与手动不占额、#51 正文回带与去重、#52 两个页签都能保存、#53 名额与 `list/drop/update`(8 条)、冷落按追加顺序与写盘入口校验(4 条)、回复不重复投递(3 条)、`notice_one` 补卡与轮换(4 条)、跨会话处境不串(3 条)、换房复核看 blank 与退路不造空壳(8 条)、只读副本不写 `seen:` 与同进程观察者去重(3 条)。都是「把判据改回去就红」的回归。
42
+
43
+ ## 还没做的
44
+
45
+ - 五个 PR 在本地以提交形式合入(作者署名保留),GitHub 上仍显示 open——本机 443 不通,等发布时一并合并、回帖说明。
46
+ - issue #51–#53 的回帖已发,等这一版发布后再关。
package/RELEASE.md CHANGED
@@ -24,8 +24,8 @@ gh auth status # 必须是 ssamurias
24
24
 
25
25
  ## 1. 发布前自检(每次都要跑,别跳)
26
26
 
27
- - [ ] `package.json` 的 `version` 就是要发的号(0.9.x 阶段只发 patch)
28
- - [ ] `node --test` 全绿(0.9.1 基线:**1512 pass / 0 fail**,约 22 秒)
27
+ - [ ] `package.json` 的 `version` 就是要发的号(修 bug 发 patch,如 `0.9.3`;一批改动发 minor,如 `0.10.0`)
28
+ - [ ] `node --test` 全绿(当前基线见 `AGENTS.md` 第 6 节;0.9.2 是 **1596 pass / 0 fail**,约 20 秒)
29
29
  - [ ] `CHANGELOG.md` 顶部有本节,条目按 issue 编号写全
30
30
  - [ ] `RELEASE-NOTES-x.y.z.md` 已写,且与 `CHANGELOG.md` 口径一致(issue 条数、PR 编号、日期)
31
31
  - [ ] `README.md` 顶部「当前版本」段已换成这一版
@@ -69,6 +69,7 @@ git push origin main
69
69
  ## 4. 发 npm 包(需要你手动跑,可整段复制)
70
70
 
71
71
  > 这一段是给维护者本人执行的:登录和 2FA 都在你的终端里完成,agent 不代跑发布。
72
+ > **本机的 2FA 是 Windows 系统密钥(安全密钥)**,所以每次发布都要走一遍完整登录流程;发完后 npm 的 CDN 大约要 **3 分钟** 才看得到新版本,核对命令等一会儿再跑。
72
73
  > **本机 npm 默认 registry 是 `https://registry.npmmirror.com`,所以每条命令都必须显式带 `--registry=https://registry.npmjs.org/`**,否则会得到看似「包不存在 / 没权限」的 E404。
73
74
 
74
75
  ```bash
@@ -84,7 +85,7 @@ npm whoami --registry=https://registry.npmjs.org/
84
85
  # 2) 发布(开了 2FA 就在末尾加 --otp=123456)
85
86
  npm publish --registry=https://registry.npmjs.org/
86
87
 
87
- # 3) 核对:应为 0.9.x 和 latest -> 0.9.x
88
+ # 3) 核对:等约 3 分钟再跑,应为 0.9.x 和 latest -> 0.9.x
88
89
  npm view @ssamurias/dsh-fish version dist-tags --registry=https://registry.npmjs.org/
89
90
  ```
90
91
 
@@ -126,8 +127,8 @@ gh release view v0.9.x
126
127
 
127
128
  - `dist-tags.latest` 要指向新版本;`dist.tarball` 能取到 URL 就说明包在。
128
129
  - 想真装一遍:**先关 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 发布」。
130
+ - 回 issue:这一版修到的每条 issue 各回一条「x.y.z 已发布,装法见 README,有问题随时回」,等作者确认后再关。
131
+ - 给这一版合入的 PR 作者留一句「已随 x.y.z 发布」;PR 没合并的不要在 GitHub 上点 merge,在回帖里说清「以提交形式合入」。
131
132
 
132
133
  ---
133
134
 
package/lib/absence.js CHANGED
@@ -19,6 +19,7 @@
19
19
  import { readEvents, appendEvent, rootDir } from './store.js'
20
20
  import { deriveSelf } from './derive.js'
21
21
  import { loadConfig } from './settings.js'
22
+ import { tsMs, isContactEvent } from './schema.js'
22
23
 
23
24
  const DAY_MS = 86_400_000
24
25
 
@@ -34,28 +35,33 @@ const DAY_MS = 86_400_000
34
35
  * 为什么**不**认 `utterance`:定时唤醒也会让她写 utterance,而「定时器叫醒她自己想事情
35
36
  * 不算他来过」是这个模块的立身之本(见文件头)。derive 层分不出哪条是唤醒里写的。
36
37
  */
38
+ /** 最后一条「他来过」在事件流里的下标;没有就 -1。 */
39
+ export function lastContactIndex(events = []) {
40
+ for (let i = events.length - 1; i >= 0; i--) if (isContactEvent(events[i])) return i
41
+ return -1
42
+ }
43
+
44
+ /** 最后一条 absence 在事件流里的下标;没有就 -1。 */
45
+ export function lastAbsenceIndex(events = []) {
46
+ for (let i = events.length - 1; i >= 0; i--) if (events[i]?.type === 'absence') return i
47
+ return -1
48
+ }
49
+
37
50
  export function lastSeenAt(events) {
51
+ // 取「最大时刻」而不是最后一条:导入/手写夹具里事件不保证按时间排。
38
52
  let at = 0
39
- for (const e of events) {
40
- if (e?.type === 'heard') {
41
- const ms = Date.parse(String(e.ts ?? ''))
42
- if (Number.isFinite(ms) && ms > at) at = ms
43
- continue
44
- }
45
- if (e?.type !== 'meta') continue
46
- const t = String(e.text ?? '')
47
- if (!t.startsWith('seen:')) continue
48
- const ms = Date.parse(t.slice(5).trim())
49
- if (Number.isFinite(ms) && ms > at) at = ms
53
+ for (const e of events ?? []) {
54
+ if (!isContactEvent(e)) continue
55
+ const ms = e?.type === 'heard' ? tsMs(e) : Date.parse(String(e.text ?? '').slice(5).trim()) || tsMs(e)
56
+ if (ms > at) at = ms
50
57
  }
51
58
  return at
52
59
  }
53
60
 
54
61
  /** 从事件流里找最近一条 absence 事件(返回事件对象或 null)。 */
55
62
  export function lastAbsenceEvent(events) {
56
- let found = null
57
- for (const e of events) if (e?.type === 'absence') found = e
58
- return found
63
+ const i = lastAbsenceIndex(events)
64
+ return i < 0 ? null : events[i]
59
65
  }
60
66
 
61
67
  /**
@@ -64,26 +70,34 @@ export function lastAbsenceEvent(events) {
64
70
  */
65
71
  export function assess(events, now = Date.now(), opts = {}) {
66
72
  const threshold = Math.max(1, Number(opts.afterDays ?? 2))
67
- const seen = lastSeenAt(events)
68
- const lastAbs = lastAbsenceEvent(events)
73
+ const seenIdx = lastContactIndex(events)
74
+ const absIdx = lastAbsenceIndex(events)
69
75
 
70
76
  // 从来没有过 contact(刚装、还没聊过)→ 不判冷落。
71
77
  // 否则第一次启动就会立刻"冷落 N 天",她开局就在怨你,这是错的。
72
- if (!seen) return { absent: false, days: 0, since: 0, reason: 'no-contact-yet' }
78
+ if (seenIdx < 0) return { absent: false, days: 0, since: 0, reason: 'no-contact-yet' }
73
79
 
74
- const days = Math.floor((now - seen) / DAY_MS)
80
+ const since = lastSeenAt(events)
81
+ // 时钟回拨、seen 落在未来时差值是负的 → 不能出现「-1 天没来」,按 0 算。
82
+ const days = Math.max(0, Math.floor((now - since) / DAY_MS))
75
83
 
76
- // 已经记过一次,且记完之后他又来过 → 这次冷落已经结束
77
- if (lastAbs && seen > Date.parse(String(lastAbs.ts ?? 0))) {
78
- return { absent: false, days: 0, since: seen, reason: 'returned' }
79
- }
80
- // 已经记过,他也还没回来 → 还是同一次冷落,不重复写
81
- if (lastAbs && seen <= Date.parse(String(lastAbs.ts ?? 0))) {
82
- return { absent: false, days, since: seen, reason: 'already-recorded' }
84
+ if (absIdx >= 0) {
85
+ // 「回来没有」按两条线索判:
86
+ // 1. 事件流里的先后(seen 写在 absence 之后)——老代码只看时间戳,
87
+ // 系统时间被往前跳过时会写下一条 ts 在未来的 absence,之后所有
88
+ // seen 的时间戳都比它小,于是「他回来了」永远判不出来。
89
+ // 2. 时间戳(seen 晚于 absence 的 ts)——兼容测试/导入数据里按时间排序、
90
+ // 但 absence 被追加在中间的情形。两条任一成立都算回来。
91
+ const back = seenIdx > absIdx || since > tsMs(events[absIdx])
92
+ // 已经记过一次,且记完之后他又来过 → 这次冷落已经结束。
93
+ // 只管门槛以内:回来之后又消失够久,就是新的一次,得能再记。
94
+ if (back && days < threshold) return { absent: false, days: 0, since, reason: 'returned' }
95
+ // 已经记过,他也还没回来 → 还是同一次冷落,不重复写
96
+ if (!back) return { absent: false, days, since, reason: 'already-recorded' }
83
97
  }
84
98
 
85
- if (days < threshold) return { absent: false, days, since: seen, reason: 'within-threshold' }
86
- return { absent: true, days, since: seen, reason: 'absent' }
99
+ if (days < threshold) return { absent: false, days, since, reason: 'within-threshold' }
100
+ return { absent: true, days, since, reason: 'absent' }
87
101
  }
88
102
 
89
103
  /**
package/lib/ask.js CHANGED
@@ -27,7 +27,9 @@
27
27
  */
28
28
 
29
29
  import { toJsonSchema } from './toolschema.js'
30
- import { rootDir, readEvents, appendEvent } from './store.js'
30
+ import { appendFile } from 'node:fs/promises'
31
+
32
+ import { rootDir, readEvents, appendEvent, paths } from './store.js'
31
33
  import { loadConfig, updateConfig, dateKey } from './settings.js'
32
34
  import { deriveSelf } from './derive.js'
33
35
  import { defineTool } from './tool.js'
@@ -43,13 +45,16 @@ export const ASK_KINDS = Object.freeze(['need-file', 'need-tool', 'time', 'rest'
43
45
  export const ASK_PER_DAY = 1
44
46
  export const ASK_PER_WEEK = 3
45
47
 
48
+ /** kind=time 的时段,和 schema.js 存得下的一样。 */
49
+ const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/
50
+
46
51
  /** 超过这么久还没人答,记成 later(以后再说),不占着「待处理」。 */
47
52
  export const ASK_TTL_MS = 24 * 3600 * 1000
48
53
 
49
54
  /** 默认的两个选项。第一个是同意,第二个是不同意。 */
50
55
  const DEFAULT_OPTIONS = ['可以', '这次不行']
51
56
 
52
- const VIA_LABEL = { chat: '会话里答的', ui: '观测台答的', timeout: '一直没人答', tool: '工具里答的' }
57
+ const VIA_LABEL = { chat: '会话里答的', ui: '观测台答的', timeout: '一直没人答', tool: '工具里答的', relay: '别的插件转回来的' }
53
58
 
54
59
  /** 从事件流里拿全部 ask / answer。 */
55
60
  export function asksOf(events = []) {
@@ -82,7 +87,8 @@ export function pendingAsks(events = [], now = Date.now()) {
82
87
  /** 配额:今天问过没有、最近七天问过几次。 */
83
88
  export function quotaOf(events = [], { now = Date.now(), dk = dateKey() } = {}) {
84
89
  const asks = asksOf(events)
85
- const today = asks.filter((e) => String(e.day ?? '') === dk || dayKeyOf(e, now) === dk).length
90
+ // 真的事件上 day 是成长第几天(一个整数),不是日期,只能按时间算。
91
+ const today = asks.filter((e) => dateKey(new Date(tsMs(e) || now)) === dk).length
86
92
  const week = asks.filter((e) => now - tsMs(e) < 7 * 24 * 3600 * 1000).length
87
93
  return {
88
94
  day: today,
@@ -94,15 +100,6 @@ export function quotaOf(events = [], { now = Date.now(), dk = dateKey() } = {})
94
100
  }
95
101
  }
96
102
 
97
- /**
98
- * 事件上没写 day 时(老夹具、单测直接构造)用本地日期补一个。
99
- * 真实写入的事件都带 day(appendEvent 由 deriveSelf 算),这里只是兜底。
100
- */
101
- function dayKeyOf(ev, now = Date.now()) {
102
- if (ev?.day) return String(ev.day)
103
- return dateKey(new Date(tsMs(ev) || now))
104
- }
105
-
106
103
  /** 她今天请过假吗(rest 被同意后写一条 meta)。 */
107
104
  export function restedToday(events = [], dk = dateKey()) {
108
105
  return events.some((e) => e?.type === 'meta' && String(e.text ?? '').startsWith(`rest:${dk}`))
@@ -132,14 +129,16 @@ export function askView(events = [], now = Date.now()) {
132
129
 
133
130
  /** 选项列表(去掉空串,最多三个,保证第一个是「可以」)。 */
134
131
  function optionsOf(raw) {
135
- const list = Array.isArray(raw) ? raw.map((x) => String(x ?? '').trim()).filter(Boolean).slice(0, 3) : []
132
+ // 事件里每个选项只存 24 个字(schema.js)。卡上的字要和账上一样,隔很久送回来的回答才对得上。
133
+ const list = Array.isArray(raw) ? raw.map((x) => String(x ?? '').trim().slice(0, 24).replace(/[\uD800-\uDBFF]$/, '').trim()).filter(Boolean).slice(0, 3) : []
136
134
  return list.length ? list : DEFAULT_OPTIONS.slice()
137
135
  }
138
136
 
139
137
  /** 裁决落地:只有 approve 会动东西,而且只动 time / rest 两种。 */
140
138
  async function applyVerdict(root, askEv, verdict, { log = () => {} } = {}) {
141
139
  const kind = String(askEv?.ask?.kind ?? '')
142
- const dk = dateKey()
140
+ // 回答可能隔天才来。她问的是问的那一天能不能歇,不是答的这一天。
141
+ const dk = dateKey(new Date(tsMs(askEv) || Date.now()))
143
142
  const day = Number(askEv?.day ?? 0) || undefined
144
143
  const ctx = { day, stage: Number(askEv?.stage ?? 1) || 1, source: 'system' }
145
144
  try {
@@ -179,6 +178,7 @@ async function applyVerdict(root, askEv, verdict, { log = () => {} } = {}) {
179
178
  await appendEvent({ type: 'meta', text: `ask-ok:${askEv.id}` }, ctx, root)
180
179
  } catch (err) {
181
180
  log(`请求事件:裁决落地失败 ${String(err?.message ?? err).slice(0, 160)}`)
181
+ throw err
182
182
  }
183
183
  }
184
184
 
@@ -186,7 +186,19 @@ async function applyVerdict(root, askEv, verdict, { log = () => {} } = {}) {
186
186
  * 记下答案(这件事只做一次)。重复答会被拒。
187
187
  * @returns {Promise<{ok: boolean, why?: string, answer?: object}>}
188
188
  */
189
- export async function settleAsk(root, { askId, verdict, reason = '', via = 'ui', now = Date.now() } = {}) {
189
+ export function settleAsk(root, opts = {}) {
190
+ // 先读账再写账,中间隔着好几次 await。回答可能从几条路同时来(卡片、观测台、别的插件、过期结算),
191
+ // 不排队的话两条都看到「还没答」,账上就留下相反的两个裁决,两边的效果还都生效了。
192
+ const run = (settling.get(root) ?? Promise.resolve()).then(() => settleAskNow(root, opts))
193
+ const tail = run.catch(() => {})
194
+ settling.set(root, tail)
195
+ void tail.then(() => { if (settling.get(root) === tail) settling.delete(root) })
196
+ return run
197
+ }
198
+
199
+ const settling = new Map()
200
+
201
+ async function settleAskNow(root, { askId, verdict, reason = '', via = 'ui', now = Date.now() } = {}) {
190
202
  const id = String(askId ?? '')
191
203
  if (!id) return { ok: false, why: '没说是哪一条请求' }
192
204
  const v = verdict === 'approve' || verdict === 'reject' || verdict === 'later' ? verdict : ''
@@ -195,19 +207,28 @@ export async function settleAsk(root, { askId, verdict, reason = '', via = 'ui',
195
207
  const { events } = await readEvents(root)
196
208
  const askEv = events.find((e) => e?.id === id && e?.type === 'ask')
197
209
  if (!askEv) return { ok: false, why: '事件流里没有这一条请求' }
198
- if (answersByAsk(events).has(id)) return { ok: false, why: '这条已经答过了' }
210
+ const prior = answersByAsk(events).get(id)
211
+ if (prior) return { ok: false, why: prior.ask?.via === 'timeout' ? '放了一天没人答,已经记成以后再说了' : '这条已经答过了' }
199
212
 
200
213
  const { config } = await loadConfig(root)
201
214
  const model = deriveSelf(events, { growthDays: config.growthDays })
202
215
  const word = v === 'approve' ? '可以' : v === 'reject' ? '这次不行' : '以后再说'
203
216
  const text = String(reason ?? '').trim().slice(0, 200) || word
217
+ // 先让裁决生效,再记「答过了」。反过来的话,配置写不进去也只会回「已经答过了」,再也改不回来,
218
+ // 账上和她听到的都是答应了,其实什么都没变。效果重放无害:同样的补丁,多一行 meta。
219
+ try {
220
+ // 账写不进去就别先改配置:不然他听到「没记上」,她的作息却已经变了。
221
+ await appendFile(paths(root).events, '')
222
+ await applyVerdict(root, askEv, v, { log: (m) => logLine('warn', [m], root) })
223
+ } catch (err) {
224
+ return { ok: false, why: `这次没落地:${String(err?.code ?? err?.message ?? err).slice(0, 80)}` }
225
+ }
204
226
  const answer = await appendEvent({
205
227
  type: 'answer',
206
228
  text,
207
229
  ask: { askId: id, verdict: v, reason: String(reason ?? '').slice(0, 200), via },
208
230
  }, { day: model.day, stage: model.stage, source: 'system' }, root)
209
231
 
210
- await applyVerdict(root, askEv, v, { log: (m) => logLine('warn', [m], root) })
211
232
  return { ok: true, answer }
212
233
  }
213
234
 
@@ -256,21 +277,33 @@ export function askChoiceIndex(options = [], selected = '') {
256
277
  return -1
257
278
  }
258
279
 
280
+ /** 改作息的问题,话是她写的,答应了改成什么是另一回事:把真的会写进配置的数写在卡上。 */
281
+ function valueNote(value) {
282
+ const bits = []
283
+ if (Number.isFinite(Number(value?.wakesPerDay))) bits.push(`每天醒 ${Number(value.wakesPerDay)} 次`)
284
+ if (Array.isArray(value?.window) && value.window.length === 2) bits.push(`${value.window[0]}–${value.window[1]}`)
285
+ return bits.length ? `(答应了就改成:${bits.join(',')})` : ''
286
+ }
287
+
259
288
  /** 把她的问题丢进会话卡片,等人选。返回 {state, note, said, code, raw}。 */
260
- async function askInCard(ctx, { sessionId, ev, options, signal }) {
289
+ async function askInCard(ctx, { sessionId, ev, options, signal, rawTexts, root }) {
261
290
  const r = await askInChat(ctx, {
262
291
  sessionId,
263
292
  signal,
293
+ rawTexts,
294
+ root,
295
+ cardId: `ask:${ev.id}`,
296
+ detail: String(ev?.ask?.reason ?? ''),
264
297
  questions: [
265
298
  {
266
299
  id: `ask-${ev.id}`,
267
300
  header: '大肥鱼有事想问你',
268
- question: String(ev.text ?? ''),
301
+ question: `${String(ev.text ?? '')}${valueNote(ev?.ask?.value)}`,
269
302
  options: options.map((label) => ({ label })),
270
303
  },
271
304
  ],
272
305
  })
273
- if (!r.ok) return { state: 'unanswered', note: '问了,还没答。', said: '', code: r.code ?? 'NO_ANSWER', raw: '' }
306
+ if (!r.ok) return { state: 'unanswered', note: '问了,还没答。', said: '', code: r.code ?? 'NO_ANSWER', raw: '', via: r.via ?? '' }
274
307
  const pid = `ask-${ev.id}`
275
308
  // issue #35:他直接在卡片里打字回答时,宿主回的是 `{id, selected: [], custom: '…'}`
276
309
  // (dsh-user-questions 的约定:custom 覆盖选项、selected 留空)。以前这里只看
@@ -348,6 +381,7 @@ export function registerAsk(ctx, deps = {}) {
348
381
  note: { type: 'string' },
349
382
  said: { type: 'string' },
350
383
  askId: { type: 'string' },
384
+ via: { type: 'string' },
351
385
  },
352
386
  required: ['ok', 'note'],
353
387
  },
@@ -362,7 +396,9 @@ export function registerAsk(ctx, deps = {}) {
362
396
  ? '[这次不行]'
363
397
  : out.said === 'later'
364
398
  ? '[以后再说]'
365
- : '[已问]',
399
+ : out.said === 'relayed'
400
+ ? `[已问,送到了${out.via},还没答]`
401
+ : '[已问]',
366
402
  },
367
403
  ],
368
404
  },
@@ -386,10 +422,19 @@ export function registerAsk(ctx, deps = {}) {
386
422
  if (!quota.ok) return { ok: false, note: quota.why }
387
423
 
388
424
  const options = optionsOf(args?.options)
425
+ // 回答认的是选项原文,两个一样的话选第二个也算第一个。
426
+ if (new Set(options).size < options.length) return { ok: false, note: '选项的前 24 个字不能一样,不然他分不出来。' }
389
427
  const value = {}
390
428
  if (kind === 'time') {
391
- if (Number.isFinite(Number(args?.wakesPerDay))) value.wakesPerDay = Number(args.wakesPerDay)
392
- if (Array.isArray(args?.window) && args.window.length === 2) value.window = args.window.map((x) => String(x))
429
+ // 账上只存得下 0–4 的整数(schema.js 会悄悄改成最近的)。她传 5,答应后是 4,她却一直以为是 5;
430
+ // 传 -1 就成了 0,也就是不再定时醒。所以不认的都不问,这时还没写事件,不占额度。
431
+ const w = args?.wakesPerDay
432
+ if (/^[0-4]$/.test(String(typeof w === 'string' ? w.trim() : w))) value.wakesPerDay = Number(w)
433
+ const win = Array.isArray(args?.window) ? args.window.map((x) => String(x)) : null
434
+ if (win?.length === 2 && win.every((x) => HHMM.test(x)) && win[0] !== win[1]) value.window = win
435
+ if (!Object.keys(value).length || (w != null && value.wakesPerDay === undefined) || (args?.window != null && !value.window)) {
436
+ return { ok: false, note: 'kind=time 要写 wakesPerDay(0–4)或 window(两个不同的 HH:MM,比如 08:00)。写不对的话他答应了也改不了。' }
437
+ }
393
438
  }
394
439
 
395
440
  const { config } = await loadConfig(root)
@@ -407,7 +452,10 @@ export function registerAsk(ctx, deps = {}) {
407
452
  }, { day: model.day, stage: model.stage, source: 'system' }, root)
408
453
 
409
454
  // 卡片只挂在那条会话上;她这轮刚起来时 agent 可能在注册路上,askInChat 会等一小会儿。
410
- const r = await askInCard(ctx, { sessionId, ev, options, signal: exec?.signal })
455
+ const rawTexts = [args?.question, args?.why, ...(Array.isArray(args?.options) ? args.options : [])]
456
+ const r = await askInCard(ctx, { sessionId, ev, options, signal: exec?.signal, rawTexts, root })
457
+ // 别的插件接走了:不是没人理,只是还没答。答了走 lib/cardanswer.js 结算,这里不等。
458
+ if (r.code === 'RELAYED') return { ok: true, note: `问了,送到了${r.via},还没答。`, said: 'relayed', askId: ev.id, via: r.via }
411
459
  if (r.state === 'unanswered') {
412
460
  // 2026-10-30 真机(用户 m01756):卡片消失了,事件流里却没有 answer 行,
413
461
  // 回执只写「已问」——事后完全看不出是「没答」还是「认不出选择」。
@@ -420,7 +468,7 @@ export function registerAsk(ctx, deps = {}) {
420
468
  return {
421
469
  ok: true,
422
470
  note: s.ok ? r.note : '问了,但这次没记下来。',
423
- said: r.state,
471
+ said: s.ok ? r.state : '',
424
472
  askId: ev.id,
425
473
  }
426
474
  },