@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.
- package/AGENTS.md +60 -4
- package/CHANGELOG.md +85 -1
- package/README.md +13 -6
- package/RELEASE-NOTES-0.9.1.md +296 -0
- package/RELEASE-NOTES-0.9.2.md +46 -0
- package/RELEASE.md +152 -0
- package/lib/absence.js +41 -27
- package/lib/ask.js +91 -30
- package/lib/askinchat.js +83 -1
- package/lib/browse.js +1 -0
- package/lib/capture.js +35 -2
- package/lib/cardanswer.js +84 -0
- package/lib/client.js +116 -19
- package/lib/comment.js +111 -2
- package/lib/compact.js +45 -1
- package/lib/curiosity.js +6 -2
- package/lib/deliver.js +46 -0
- package/lib/derive.js +25 -13
- package/lib/desk.js +3 -1
- package/lib/dreamtable.js +13 -2
- package/lib/drive.js +14 -2
- package/lib/events.js +25 -1
- package/lib/freewake.js +4 -1
- package/lib/generator.js +2 -1
- package/lib/grant.js +18 -2
- package/lib/home.js +142 -29
- package/lib/identity.js +111 -14
- package/lib/index.js +50 -10
- package/lib/interest.js +3 -1
- package/lib/keep.js +21 -9
- package/lib/keepsake.js +20 -13
- package/lib/look.js +23 -3
- package/lib/note.js +255 -20
- package/lib/observe.js +14 -4
- package/lib/owner.js +5 -2
- package/lib/persona.js +26 -9
- package/lib/proactive.js +67 -12
- package/lib/say.js +73 -13
- package/lib/scheduler.js +186 -56
- package/lib/schema.js +18 -0
- package/lib/selfdoc.js +5 -3
- package/lib/selfupdate.js +8 -1
- package/lib/settings.js +5 -0
- package/lib/status.js +56 -6
- package/lib/store.js +14 -2
- package/lib/tenant.js +12 -0
- package/lib/turns.js +47 -2
- package/lib/upgrade.js +4 -1
- package/package.json +2 -1
- 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
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
68
|
-
const
|
|
73
|
+
const seenIdx = lastContactIndex(events)
|
|
74
|
+
const absIdx = lastAbsenceIndex(events)
|
|
69
75
|
|
|
70
76
|
// 从来没有过 contact(刚装、还没聊过)→ 不判冷落。
|
|
71
77
|
// 否则第一次启动就会立刻"冷落 N 天",她开局就在怨你,这是错的。
|
|
72
|
-
if (
|
|
78
|
+
if (seenIdx < 0) return { absent: false, days: 0, since: 0, reason: 'no-contact-yet' }
|
|
73
79
|
|
|
74
|
-
const
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
86
|
-
return { absent: true, days, since
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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
|
|
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
|
|
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
|
},
|