@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
@@ -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 ADDED
@@ -0,0 +1,152 @@
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` 就是要发的号(修 bug 发 patch,如 `0.9.3`;一批改动发 minor,如 `0.10.0`)
28
+ - [ ] `node --test` 全绿(当前基线见 `AGENTS.md` 第 6 节;0.9.2 是 **1596 pass / 0 fail**,约 20 秒)
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
+ > **本机的 2FA 是 Windows 系统密钥(安全密钥)**,所以每次发布都要走一遍完整登录流程;发完后 npm 的 CDN 大约要 **3 分钟** 才看得到新版本,核对命令等一会儿再跑。
73
+ > **本机 npm 默认 registry 是 `https://registry.npmmirror.com`,所以每条命令都必须显式带 `--registry=https://registry.npmjs.org/`**,否则会得到看似「包不存在 / 没权限」的 E404。
74
+
75
+ ```bash
76
+ cd "D:/deepseekagent/02-我的项目/大肥鱼/plugin"
77
+
78
+ # 0) 确认身份:返回 ssamurias 就说明本机已经登录,可以跳过第 1 步
79
+ npm whoami --registry=https://registry.npmjs.org/
80
+
81
+ # 1) 只有第 0 步报 E401 / 不是 ssamurias 时才需要:浏览器登录
82
+ npm login --registry=https://registry.npmjs.org/
83
+ npm whoami --registry=https://registry.npmjs.org/
84
+
85
+ # 2) 发布(开了 2FA 就在末尾加 --otp=123456)
86
+ npm publish --registry=https://registry.npmjs.org/
87
+
88
+ # 3) 核对:等约 3 分钟再跑,应为 0.9.x 和 latest -> 0.9.x
89
+ npm view @ssamurias/dsh-fish version dist-tags --registry=https://registry.npmjs.org/
90
+ ```
91
+
92
+ - **不想每条都带参数**:`npm config set registry https://registry.npmjs.org/`,发完再改回 `npm config set registry https://registry.npmmirror.com/`。
93
+ - **scoped 包没有写权限时 npm 故意返回 404(不是 403)**:看到 E404 先查 `npm whoami`,别怀疑包名或网络。
94
+ - 本包已经是公开包,**不需要** `--access public`(只有首次发布才要)。
95
+ - 常见报错:
96
+ - `E401`:没登录 / token 过期 → `npm logout --registry=...` 再 `npm login --registry=...`
97
+ - `E403`:账号没有 `@ssamurias` scope 写权限,或开了 2FA 没给 `--otp`
98
+ - `EPUBLISHCONFLICT`:这个版本号已经发过 → 已发布版本不能覆盖,升 patch 号重来
99
+ - `npm publish --dry-run` 可以先看会打进去哪些文件,不会真的发。
100
+ - 用户端注意:pnpm 的 `minimumReleaseAge`(默认 24 小时)会把「太新」的版本回滚掉;本机 profile 已设 `minimumReleaseAge: 0`(避坑指南第 3 节)。
101
+
102
+ ## 5. 打 tag + 建 GitHub Release
103
+
104
+ **npm 发布成功之后**再做这一步,npm 失败就停住、别打 tag(免得留一个指向没发出去的版本的悬空 tag)。
105
+
106
+ ```bash
107
+ git tag -a v0.9.x -m "0.9.x"
108
+ git push origin v0.9.x
109
+ export PATH="/c/Users/DANG/bin:$PATH"
110
+ gh release create v0.9.x -F RELEASE-NOTES-0.9.x.md --title "0.9.x"
111
+ ```
112
+
113
+ - Release 正文直接用仓库里的 `RELEASE-NOTES-*.md`(`-F` 读文件),不要在网页上手打——两边会不一致。
114
+ - 想先存草稿:`gh release create v0.9.x -F RELEASE-NOTES-0.9.x.md --draft`,检查完再 `gh release edit v0.9.x --draft=false`。
115
+ - `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>`。
116
+ - 兜底也可以在网页上建 Release(会自动建 tag),但正文仍以 `RELEASE-NOTES-*.md` 为准,别手打。
117
+
118
+ ---
119
+
120
+ ## 6. 发布后验证
121
+
122
+ ```bash
123
+ npm view @ssamurias/dsh-fish version dist-tags
124
+ npm view @ssamurias/dsh-fish@0.9.x dist.tarball
125
+ gh release view v0.9.x
126
+ ```
127
+
128
+ - `dist-tags.latest` 要指向新版本;`dist.tarball` 能取到 URL 就说明包在。
129
+ - 想真装一遍:**先关 DSH Desktop**,用临时 profile 试装(不要直接动 `desktop` profile)——`bundles` 与 `dependencies` 必须一一对应,先只改依赖、装包,再用目录联接的临时 profile 跑 `--dump-config` 预演(避坑指南第 1 节)。
130
+ - 回 issue:这一版修到的每条 issue 各回一条「x.y.z 已发布,装法见 README,有问题随时回」,等作者确认后再关。
131
+ - 给这一版合入的 PR 作者留一句「已随 x.y.z 发布」;PR 没合并的不要在 GitHub 上点 merge,在回帖里说清「以提交形式合入」。
132
+
133
+ ---
134
+
135
+ ## 7. 出问题时怎么回滚
136
+
137
+ | 对象 | 命令 | 说明 |
138
+ | --- | --- | --- |
139
+ | npm 版本 | `npm unpublish @ssamurias/dsh-fish@0.9.x` | 72 小时内可撤;超过 72 小时改用 `npm deprecate @ssamurias/dsh-fish@0.9.x "原因,请装 x.y.z"`,不要再发同名版本 |
140
+ | 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` |
141
+ | GitHub Release | `gh release delete v0.9.x` | 加 `--cleanup-tag` 连 tag 一起删 |
142
+ | 代码 | 用 `git revert <提交>` 再正常 push | **不要** force push `main` |
143
+
144
+ ---
145
+
146
+ ## 8. 这个项目特有的坑(发布前扫一眼)
147
+
148
+ - **包名 / 迁移脚本别动**:`@ssamurias/dsh-fish`、`cordis.patch.yml` 的 `id: dsh-fish`、`lib/selfupdate.js` 的 `normalizeScript()` 三条都在 `AGENTS.md` 的「包名与装法」,发布前改它们等于给自己造升级事故。
149
+ - **升级说明要和脚本一致**:`scripts/fix-legacy-link.mjs` 现在认三种依赖写法(`link:` / `file:` tgz / 版本号)。改了它,`README.md` 升级表与 `RELEASE-NOTES-*.md` 必须同步改,否则用户照文档做还是会崩。
150
+ - **`desktop` profile 被应用独占**:`dsh plugin --profile desktop ...` 会被拒,验证要用别的 profile。
151
+ - **别在应用运行时替换已加载的包**:`ERR_PNPM_EPERM`,包目录会被留成「已删除」。
152
+ - 备份照旧放 `D:/deepseekagent/dsh-fix-backup/`,不要放进仓库。
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,12 +27,14 @@
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'
34
36
  import { callerOf } from './tenant.js'
35
- import { askInChat } from './askinchat.js'
37
+ import { askInChat, picked } from './askinchat.js'
36
38
  import { tsMs } from './schema.js'
37
39
  import { logLine } from './log.js'
38
40
 
@@ -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,26 +277,51 @@ 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
- 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
308
+ // issue #35:他直接在卡片里打字回答时,宿主回的是 `{id, selected: [], custom: '…'}`
309
+ // (dsh-user-questions 的约定:custom 覆盖选项、selected 留空)。以前这里只看
310
+ // `Array.isArray(selected)`,空数组当成了「选了第一项里的 undefined」,于是
311
+ // 手打的答案永远认不出来、一律记成 NO_MATCH。`askinchat.js` 的 picked() 早就
312
+ // 按这个约定写对了(非空 selected 优先,否则退回 custom),这里复用它。
313
+ const sel = picked(r.answers, pid)
277
314
  const idx = askChoiceIndex(options, sel)
278
- const raw = JSON.stringify(r.answers ?? []).slice(0, 300)
315
+ // issue #35 后半:这句 raw 会被 logLine 写进日志,以前是整段回执原文——
316
+ // 他手打的自由回答(可能是私事)就这样落进了日志文件。现在只留形状:
317
+ // 每条回执的 id、选了几个、自定义文本多长。排障够用,原文不落地。
318
+ const raw = JSON.stringify(
319
+ (Array.isArray(r.answers) ? r.answers : []).map((a) => ({
320
+ id: a?.id,
321
+ selected: Array.isArray(a?.selected) ? a.selected.length : '-',
322
+ custom: typeof a?.custom === 'string' ? a.custom.length : 0,
323
+ })),
324
+ ).slice(0, 300)
279
325
  if (idx === 0) return { state: 'approved', note: '可以。', said: String(options[0] ?? ''), raw }
280
326
  if (idx === 1) return { state: 'rejected', note: '这次不行。', said: String(options[1] ?? ''), raw }
281
327
  if (idx === 2) return { state: 'later', note: '以后再说。', said: String(options[2] ?? ''), raw }
@@ -335,6 +381,7 @@ export function registerAsk(ctx, deps = {}) {
335
381
  note: { type: 'string' },
336
382
  said: { type: 'string' },
337
383
  askId: { type: 'string' },
384
+ via: { type: 'string' },
338
385
  },
339
386
  required: ['ok', 'note'],
340
387
  },
@@ -349,7 +396,9 @@ export function registerAsk(ctx, deps = {}) {
349
396
  ? '[这次不行]'
350
397
  : out.said === 'later'
351
398
  ? '[以后再说]'
352
- : '[已问]',
399
+ : out.said === 'relayed'
400
+ ? `[已问,送到了${out.via},还没答]`
401
+ : '[已问]',
353
402
  },
354
403
  ],
355
404
  },
@@ -373,10 +422,19 @@ export function registerAsk(ctx, deps = {}) {
373
422
  if (!quota.ok) return { ok: false, note: quota.why }
374
423
 
375
424
  const options = optionsOf(args?.options)
425
+ // 回答认的是选项原文,两个一样的话选第二个也算第一个。
426
+ if (new Set(options).size < options.length) return { ok: false, note: '选项的前 24 个字不能一样,不然他分不出来。' }
376
427
  const value = {}
377
428
  if (kind === 'time') {
378
- if (Number.isFinite(Number(args?.wakesPerDay))) value.wakesPerDay = Number(args.wakesPerDay)
379
- 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
+ }
380
438
  }
381
439
 
382
440
  const { config } = await loadConfig(root)
@@ -394,12 +452,15 @@ export function registerAsk(ctx, deps = {}) {
394
452
  }, { day: model.day, stage: model.stage, source: 'system' }, root)
395
453
 
396
454
  // 卡片只挂在那条会话上;她这轮刚起来时 agent 可能在注册路上,askInChat 会等一小会儿。
397
- 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 }
398
459
  if (r.state === 'unanswered') {
399
460
  // 2026-10-30 真机(用户 m01756):卡片消失了,事件流里却没有 answer 行,
400
461
  // 回执只写「已问」——事后完全看不出是「没答」还是「认不出选择」。
401
462
  // 这里把 code 和原始回执留在日志里,下次照日志修,不再猜。
402
- logLine('warn', ['她问了却没结算:', r.code || 'NO_MATCH', '·', r.raw || '没有回执', '· ask=', ev.id], root)
463
+ logLine('warn', ['她问了却没结算:', r.code || 'NO_MATCH', '·', r.raw ? `回执形状 ${r.raw}` : '没有回执', '· ask=', ev.id], root)
403
464
  return { ok: true, note: '问了,等他点头。', said: '', askId: ev.id }
404
465
  }
405
466
  const verdict = r.state === 'approved' ? 'approve' : r.state === 'rejected' ? 'reject' : 'later'
@@ -407,7 +468,7 @@ export function registerAsk(ctx, deps = {}) {
407
468
  return {
408
469
  ok: true,
409
470
  note: s.ok ? r.note : '问了,但这次没记下来。',
410
- said: r.state,
471
+ said: s.ok ? r.state : '',
411
472
  askId: ev.id,
412
473
  }
413
474
  },