dsh-pisces 0.2.5 → 0.2.7

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/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  本项目遵循语义化版本;`0.x` 期间小版本可能带行为变化。
4
4
 
5
+ ## 0.2.7
6
+
7
+ - 补完 B 站登录:设置页的「扫码登录(下一步接)」换成真的登录卡。
8
+ - **手机扫码**(默认):宿主现画二维码(`lib/qr.js`,零依赖),轮询到「扫到了」→「确认登录」,
9
+ 先写清「登的是 谁(uid)」,人点「就是这个号,保存」才落盘。码 3 分钟过期,可换一张。
10
+ - **粘贴 cookie**(退路):`SESSDATA` / `bili_jct`,后端用 `nav` 接口验一次再落盘 `owner.json`
11
+ (0600 原子写,失败一个字节都不写)。
12
+ - 扫码登录移植自 dsh-fish(MIT,@Bacon-01 贡献):`lib/qr.js` 原样、`lib/biliqr.js` 按本项目改写,
13
+ 来源与许可写进 `THIRD-PARTY-NOTICES.md`。cookie 原文只活在宿主内存里,页面与日志都拿不到。
14
+ - 新增 `/bili` 接口:`GET` 只回「有没有登录 / 名字」,`POST {cookie}` 验证并保存,`DELETE` 清掉;
15
+ 扫码四个动作在 `/bili/qr`、`/bili/qr/save`、`/bili/qr/drop`。
16
+ - 去掉 settings 里只读的 `bili.qrLogin` 占位项(它永远不会变成 logged-in,登录态现在由 `owner.json` 决定)。
17
+ - 面板:B 站与百度贴吧共用同一张登录卡(`CookieCard`);扫码只给 B 站,贴吧不做扫码。
18
+ - 覆盖登录态前先把旧的留成 `owner.json.bak`:点错、扫错号还能翻回来(留备份失败不拦写盘)。
19
+
20
+ ## 0.2.6
21
+
22
+ - 好感封顶:`utterance` 累计 ≤15、`heard` ≤20、`diary` ≤10。之前把旧数据一次灌进来会直接顶到 100(5 天就「离不开」),现在 705 条、4 天算下来是 47(亲近)。
5
23
  ## 0.2.5
6
24
 
7
25
  - 补 `CHANGELOG.md`、`AGENTS.md`。
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # 鱼肥生双(dsh-pisces)
1
+ # 鱼肥生双(dsh-pisces)
2
2
 
3
3
  一只会长大的本地小鱼,DSH 插件。事件流记忆、渐进揭开、好感、新手引导;派希丝是养成目标。
4
4
 
@@ -22,6 +22,15 @@ dsh plugin add dsh-pisces
22
22
  - 工具面:普通鱼 7 个动作;成年鱼 12 个(额外自查 / 教人 / 引导 / 体检 / 昵称)。
23
23
  - 领养之后需要重启一次,工具面才会多出成年鱼的动作。
24
24
 
25
+ ## 登录(可选)
26
+
27
+ - **设置 → 鱼肥生双 → 设置**里,B 站和百度贴吧各有一张卡。
28
+ - B 站:点「用手机扫码登录」,手机 B 站扫一下、在手机上点「确认登录」,卡片上会先写清「登的是谁」,
29
+ 再点「就是这个号,保存」才写盘。不想扫码的话,另一格「粘贴 cookie」贴 `SESSDATA` / `bili_jct` 也行。
30
+ - 贴吧:把 `BDUSS` / `STOKEN` 贴进去,验过才存。
31
+ - 只存在本机(B 站 `owner.json`、贴吧 `baidu.json`,0600);面板、接口、日志都不回显 cookie,验证不过一个字节都不写。
32
+ - 不需要登录也能用:看热门是公开接口;说话、贴吧相关动作要登录。
33
+
25
34
  ## 观测台(可选)
26
35
 
27
36
  - 地址:`http://127.0.0.1:<端口>/dsh-pisces-api/`(端口就是 DSH 自己的端口)。
@@ -33,12 +42,12 @@ dsh plugin add dsh-pisces
33
42
 
34
43
  - 所有数据都在本机:事件流(SQLite 或 JSONL)、日记、人设卡、问题、梦、学到的东西。
35
44
  - 插件不上传、不收集统计,也没有遥测。
36
- - 只有你主动触发的联网动作才会出网:B 站公开接口(看热门、说一句)、自学搜索(需要 key)、夜间模型(本地或你自己配的)。
45
+ - 只有你主动触发的联网动作才会出网:B 站公开接口(看热门、说一句)、登录时各验一次(B 站 `nav` / 贴吧 `tbs`)、自学搜索(需要 key)、夜间模型(本地或你自己配的)。
37
46
  - 发布版不含开发者钥匙,也没有 `/devkey` 接口;本机路径、Cookie、私聊都不会进发布包。
38
47
 
39
48
  ## 鸣谢
40
49
 
41
- 这些是**思路借鉴**,不是代码依赖;每个项目的代码各自独立,本仓库没有复制它们的实现。完整的来源说明见 [THIRD-PARTY-NOTICES.md](./THIRD-PARTY-NOTICES.md);版本变化见 [CHANGELOG.md](./CHANGELOG.md)。
50
+ 这些是**思路借鉴**,不是代码依赖;除 B 站扫码登录(`lib/qr.js` / `lib/biliqr.js` 移植自 dsh-fish)外,每个项目的代码各自独立,本仓库没有复制它们的实现。完整的来源说明见 [THIRD-PARTY-NOTICES.md](./THIRD-PARTY-NOTICES.md);版本变化见 [CHANGELOG.md](./CHANGELOG.md)。
42
51
 
43
52
  - **dsh-fish** —— 事件流 + 纯函数派生 + 面板形态,本项目的骨架从它长出来。npm:<https://www.npmjs.com/package/@ssamurias/dsh-fish>
44
53
  - **dsh-fish-memory** —— 记忆鲜活度与半衰期、中文检索。
@@ -50,5 +59,5 @@ dsh plugin add dsh-pisces
50
59
  - **所有养过鱼的人** —— 社区里"把 AI 当室友养"的尝试。
51
60
 
52
61
  如果你觉得某个想法该被写在这里,发反馈告诉我们(反馈页有加群方式)。
53
-
54
-
62
+
63
+
@@ -5,12 +5,21 @@
5
5
 
6
6
  ## 代码来源
7
7
 
8
- 本仓库**不含上游代码派生**:`lib/` 下全部实现(事件流、派生、分流、双流、人设卡八道闸、夜间流水线、
9
- 渐进揭开、好感推导、新手引导等)均在本项目中新写,插件没有任何运行时依赖。
8
+ `lib/` 下的实现(事件流、派生、分流、双流、人设卡八道闸、夜间流水线、渐进揭开、好感推导、
9
+ 新手引导等)均在本项目中新写,插件没有任何运行时依赖。**唯一的例外是 B 站扫码登录**:
10
+
11
+ - `lib/qr.js`:**原样移植**自 dsh-fish 的 `lib/qr.js`(MIT,Copyright (c) 2026 大肥鱼(dsh-fish)作者)。
12
+ 零依赖二维码编码器,由 @Bacon-01 贡献(dsh-fish PR #17)。
13
+ - `lib/biliqr.js`:**改写移植**自 dsh-fish 的 `lib/biliqr.js`(同一 MIT 许可与版权)。
14
+ 状态机、Set-Cookie 解析与轮询节流沿用原实现;落盘改走本项目的 `readBili`/`writeBili`,
15
+ 认账号改走 `checkBili`,并要求 `bili_jct` 才允许保存。
16
+ - 两个文件顶部都保留了来源说明。dsh-fish 的 MIT 全文见
17
+ <https://github.com/ssamurias/dsh-fish/blob/main/LICENSE>。
18
+ - 其余部分(`lib/bili.js` 的热门/评论、`lib/baidu.js` 的贴吧登录)为本项目新写。
10
19
 
11
20
  ## 思想引用
12
21
 
13
- 以下项目影响了本项目的设计,但**没有复制它们的代码或文本**,实现均为重新编写:
22
+ 以下项目影响了本项目的设计,但除上面写明的扫码登录外,**没有复制它们的代码或文本**,实现均为重新编写:
14
23
 
15
24
  - **dsh-fish**(@ssamurias/dsh-fish,MIT)—— 事件流 + 纯函数派生 + 设置面板形态。
16
25
  - **dsh-fish-memory** —— 记忆鲜活度与半衰期、中文双字组检索。
package/client.js CHANGED
@@ -533,8 +533,6 @@ window.__ModuleLoader__.load({
533
533
  h('p', { className: data ? 'err' : 'empty' }, data ? `设置没读到:${data.settingsError}` : '正在读设置…'))
534
534
  }
535
535
 
536
- const bili = cur.bili || {}
537
- const logged = bili.qrLogin === 'logged-in'
538
536
  const set = (path, value) => setDraft((d) => ({ ...d, [path]: value }))
539
537
 
540
538
  async function save() {
@@ -597,27 +595,43 @@ window.__ModuleLoader__.load({
597
595
  h('div', { className: 'row' }, h('button', { type: 'button', disabled: busy, onClick: save }, '保存设置')),
598
596
  note ? h('p', { className: note === '存好了,重启后生效' ? 'hint' : 'err' }, note) : null,
599
597
  ),
600
- h(Card, { title: 'B 站', right: '只读' },
601
- h('p', { className: 'hint' }, `登录状态:${logged ? '已登录 _小苍兰_' : '未登录'}`),
602
- h('div', { className: 'row' }, h('button', { type: 'button', disabled: true }, '扫码登录(下一步接)'))),
598
+ h(BiliCard, null),
603
599
  h(BaiduCard, null),
604
600
  )
605
601
  }
606
602
 
607
- // 百度贴吧登录:粘贴 BDUSS/STOKEN → 后端验证 → 状态就地刷新;不做扫码/QQ/微信。
608
- function BaiduCard() {
603
+ // 登录卡(B 站 / 百度贴吧共用):扫码或粘贴 cookie → 后端验证 → 状态就地刷新。
604
+ // B 站那张多给一条扫码路:二维码由宿主现画(lib/qr.js),页面只拿 SVG 和「登的是谁」。
605
+ function CookieCard({ title, api, placeholder, hint, qr }) {
609
606
  const [status, setStatus] = useState(null)
610
607
  const [text, setText] = useState('')
611
608
  const [busy, setBusy] = useState(false)
612
609
  const [note, setNote] = useState('')
610
+ const [q, setQ] = useState(null)
613
611
 
614
612
  useEffect(() => {
615
613
  let alive = true
616
- getJson('/baidu')
614
+ getJson(api)
617
615
  .then((d) => { if (alive) setStatus(d) })
618
616
  .catch(() => { if (alive) setStatus({ loggedIn: false }) })
619
617
  return () => { alive = false }
620
- }, [])
618
+ }, [api])
619
+
620
+ const key = q && q.key ? String(q.key) : ''
621
+ const state = q && q.state ? String(q.state) : ''
622
+ const polling = Boolean(key) && (state === 'waiting' || state === 'scanned')
623
+
624
+ // 轮询只在这一张码还活着的时候跑;状态变了(confirmed/expired)就停。
625
+ useEffect(() => {
626
+ if (!polling) return undefined
627
+ let alive = true
628
+ const timer = setInterval(() => {
629
+ getJson(`${api}/qr?key=${encodeURIComponent(key)}`)
630
+ .then((r) => { if (alive) setQ((prev) => (prev && prev.key === key ? { ...prev, ...r } : prev)) })
631
+ .catch(() => {})
632
+ }, 1200)
633
+ return () => { alive = false; clearInterval(timer) }
634
+ }, [api, key, polling])
621
635
 
622
636
  const logged = !!(status && status.loggedIn)
623
637
  const name = status && status.name ? String(status.name) : ''
@@ -627,7 +641,7 @@ window.__ModuleLoader__.load({
627
641
  setBusy(true)
628
642
  setNote('')
629
643
  try {
630
- const r = await postJson('/baidu', { cookie: text })
644
+ const r = await postJson(api, { cookie: text })
631
645
  setStatus(r)
632
646
  setText('')
633
647
  setNote(r.name ? `登上了:${r.name}` : '登上了')
@@ -638,22 +652,104 @@ window.__ModuleLoader__.load({
638
652
  }
639
653
  }
640
654
 
655
+ async function startQr() {
656
+ if (busy) return
657
+ setBusy(true)
658
+ setNote('')
659
+ try {
660
+ setQ({ ...(await postJson(`${api}/qr`, {})), state: 'waiting' })
661
+ } catch (e) {
662
+ setNote(String((e && e.message) || e))
663
+ } finally {
664
+ setBusy(false)
665
+ }
666
+ }
667
+
668
+ async function saveQr() {
669
+ if (busy || !key) return
670
+ setBusy(true)
671
+ try {
672
+ const r = await postJson(`${api}/qr/save`, { key })
673
+ setStatus(r)
674
+ setQ(null)
675
+ setNote(r.name ? `登上了:${r.name}` : '登上了')
676
+ } catch (e) {
677
+ setNote(String((e && e.message) || e))
678
+ } finally {
679
+ setBusy(false)
680
+ }
681
+ }
682
+
683
+ function dropQr() {
684
+ const k = key
685
+ setQ(null)
686
+ if (k) postJson(`${api}/qr/drop`, { key: k }).catch(() => {})
687
+ }
688
+
689
+ const qrLine = !q
690
+ ? ''
691
+ : state === 'confirmed'
692
+ ? (q.account && q.account.name
693
+ ? `登的是 ${q.account.name}${q.account.mid ? `(uid ${q.account.mid})` : ''}`
694
+ : '扫码成功,认不出是哪个号')
695
+ : state === 'scanned'
696
+ ? '扫到了,在手机上点一下「确认登录」'
697
+ : state === 'expired'
698
+ ? '二维码过期了,点「换一张」'
699
+ : (q.why || '用手机 B 站扫一下')
700
+
641
701
  return h(
642
702
  Card,
643
- { title: '百度贴吧', right: 'Cookie' },
703
+ { title, right: 'Cookie' },
644
704
  h('p', { className: 'hint' }, `登录状态:${logged ? (name ? `已登录 ${name}` : '已登录') : '未登录'}`),
705
+ qr ? h('div', { className: 'row' }, h('button', { type: 'button', disabled: busy, onClick: startQr }, q ? '换一张' : '用手机扫码登录')) : null,
706
+ q ? h('div', { className: 'qr' },
707
+ q.svg ? h('img', {
708
+ className: 'qrimg',
709
+ src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(String(q.svg))}`,
710
+ alt: '登录二维码',
711
+ width: 180,
712
+ height: 180,
713
+ }) : null,
714
+ h('p', { className: 'hint' }, qrLine),
715
+ h('div', { className: 'row' },
716
+ state === 'confirmed' ? h('button', { type: 'button', disabled: busy, onClick: saveQr }, '就是这个号,保存') : null,
717
+ h('button', { type: 'button', onClick: dropQr }, '收起'),
718
+ ),
719
+ ) : null,
645
720
  h('textarea', {
646
721
  value: text,
647
722
  rows: 3,
648
- placeholder: '把浏览器的 BDUSS=…; STOKEN=… 贴进来',
723
+ placeholder,
649
724
  onChange: (e) => setText(e.target.value),
650
725
  }),
651
726
  h('div', { className: 'row' }, h('button', { type: 'button', disabled: busy, onClick: save }, '保存并验证')),
652
727
  note ? h('p', { className: note.startsWith('登上了') ? 'hint' : 'err' }, note) : null,
653
- h('p', { className: 'hint' }, '浏览器 F12 → Application → Cookies → tieba.baidu.com → 复制 BDUSS 和 STOKEN'),
728
+ h('p', { className: 'hint' }, hint),
654
729
  )
655
730
  }
656
731
 
732
+ // B 站登录:手机扫码为主,粘贴 SESSDATA/bili_jct 是退路;登录态落 owner.json。
733
+ function BiliCard() {
734
+ return h(CookieCard, {
735
+ title: 'B 站',
736
+ api: '/bili',
737
+ qr: true,
738
+ placeholder: '把浏览器的 SESSDATA=…; bili_jct=… 贴进来',
739
+ hint: '不想扫码:浏览器 F12 → Application → Cookies → bilibili.com → 复制 SESSDATA 和 bili_jct',
740
+ })
741
+ }
742
+
743
+ // 百度贴吧登录:粘贴 BDUSS/STOKEN;只影响贴吧,不碰 B 站。
744
+ function BaiduCard() {
745
+ return h(CookieCard, {
746
+ title: '百度贴吧',
747
+ api: '/baidu',
748
+ placeholder: '把浏览器的 BDUSS=…; STOKEN=… 贴进来',
749
+ hint: '浏览器 F12 → Application → Cookies → tieba.baidu.com → 复制 BDUSS 和 STOKEN',
750
+ })
751
+ }
752
+
657
753
  // -------------------------------------------------------------------------
658
754
  // 页签:心理活动
659
755
  // -------------------------------------------------------------------------
package/index.js CHANGED
@@ -52,7 +52,8 @@ import { nicknameOf, setNickname as writeNickname } from './lib/nickname.js'
52
52
  import { clearFixedOverride, readFixedOverride, writeFixedOverride } from './lib/personafile.js'
53
53
  import { ensureHomeWithRetry, HOME_FILE } from './lib/home.js'
54
54
  import { SCHEMA as SETTINGS_SCHEMA, mergeConfig, publicSettings, readSettings, writeSettings } from './lib/settings.js'
55
- import { hot } from './lib/bili.js'
55
+ import { clearBili, hot, loginBili, readBili } from './lib/bili.js'
56
+ import { createQrLogin } from './lib/biliqr.js'
56
57
  import { clearBaidu, loginBaidu, readBaidu } from './lib/baidu.js'
57
58
  import { speak as speakOnBili } from './lib/speak.js'
58
59
  import { guideFor as teachFor } from './lib/teach.js'
@@ -235,6 +236,9 @@ export function apply(ctx, config = {}) {
235
236
  if (voiceAdapter?.ok) log(`语音回复:已接 ${voiceAdapter.kind}`)
236
237
  else log('语音回复:未接适配器(休眠)')
237
238
 
239
+ // B 站扫码登录:key 与 cookie 只活在内存里,停用时清掉(不落盘、不进日志)。
240
+ const qrLogin = createQrLogin({ log })
241
+
238
242
  let closed = false
239
243
  let disposed = false
240
244
  let store = null
@@ -659,7 +663,7 @@ export function apply(ctx, config = {}) {
659
663
  * 失败只回 {ok:false, reason}、只 log 一行,不阻塞启动(宿主没有 workspaceRegistry 也照跑)。
660
664
  */
661
665
  async function ensureHomeOnce() {
662
- if (cfg.home?.enabled === false) return { ok: false, reason: '家没开(config.home.enabled=false)' }
666
+ if (cfg.home?.enabled === false || cfg.home?.auto === false) return { ok: false, reason: '家没开(config.home.enabled=false 或 home.auto=false)' }
663
667
  let fish = null
664
668
  try {
665
669
  const s = await live()
@@ -1144,6 +1148,42 @@ export function apply(ctx, config = {}) {
1144
1148
  return { ok: true, removed: clearBaidu(root) }
1145
1149
  }
1146
1150
 
1151
+ /** B 站登录状态:只读本地 owner.json(保存时已经验过一次,不每次开面板都联网)。 */
1152
+ async function biliStatus() {
1153
+ const saved = readBili(root)
1154
+ if (!saved) return { ok: true, loggedIn: false, name: null, hasCookie: false }
1155
+ return { ok: true, loggedIn: true, name: saved.name || null, hasCookie: true }
1156
+ }
1157
+
1158
+ /** B 站登录:粘贴 SESSDATA/bili_jct → 验证 → 落盘;失败原因由 lib/bili.js 给,不打印 cookie。 */
1159
+ function biliLogin(text) {
1160
+ return loginBili({ root, text, log })
1161
+ }
1162
+
1163
+ function biliLogout() {
1164
+ return { ok: true, removed: clearBili(root) }
1165
+ }
1166
+
1167
+ /**
1168
+ * B 站扫码登录:生成码 → 轮询 → (人点保存)才落盘。
1169
+ * 四个动作都在 lib/biliqr.js 的状态机里;这里只把 root 和库边界接上。
1170
+ */
1171
+ function biliQrStart() {
1172
+ return qrLogin.start()
1173
+ }
1174
+
1175
+ function biliQrPoll(key) {
1176
+ return qrLogin.poll(key)
1177
+ }
1178
+
1179
+ async function biliQrSave(key) {
1180
+ return qrLogin.save(key, root)
1181
+ }
1182
+
1183
+ function biliQrDrop(key) {
1184
+ return qrLogin.drop(key)
1185
+ }
1186
+
1147
1187
  /**
1148
1188
  * 语音回复状态:只回有没有适配器、是哪种。
1149
1189
  * available 同时受隐藏开关约束;没有适配器时恒为 false(宿主现在就是这样)。
@@ -1177,6 +1217,13 @@ export function apply(ctx, config = {}) {
1177
1217
  baiduStatus,
1178
1218
  baiduLogin,
1179
1219
  baiduLogout,
1220
+ biliStatus,
1221
+ biliLogin,
1222
+ biliLogout,
1223
+ biliQrStart,
1224
+ biliQrPoll,
1225
+ biliQrSave,
1226
+ biliQrDrop,
1180
1227
  // 语音回复:状态 + 条件触发。不进面板;未来桥只调 voiceReply。
1181
1228
  voice: voiceInfo,
1182
1229
  voiceReply,
@@ -1422,8 +1469,18 @@ export function apply(ctx, config = {}) {
1422
1469
  // 读不到 home.json 就跳过这条判据,不影响启动。
1423
1470
  // 两条都不满足 → 不挂。
1424
1471
  if (cfg.attachToAgents !== false) {
1425
- const herHome = join(root, 'home')
1426
1472
  const homeFile = join(root, HOME_FILE)
1473
+ /** 家目录:home.json 的 workspacePath 优先(搬家后 living 就是她家);没有就退回 <root>/home。 */
1474
+ const readHomeWorkspace = () => {
1475
+ try {
1476
+ const raw = readFileSync(homeFile, 'utf8').replace(/^\uFEFF/, '')
1477
+ const j = JSON.parse(raw)
1478
+ return String(j?.workspacePath ?? j?.cwd ?? '')
1479
+ } catch {
1480
+ return ''
1481
+ }
1482
+ }
1483
+ const herHome = readHomeWorkspace() || join(root, 'home')
1427
1484
  /** 每次现读:home.json 可能在插件启动之后才由 ensureHome 写出来。 */
1428
1485
  const readHomeSessionId = () => {
1429
1486
  try {
@@ -1494,7 +1551,16 @@ export function apply(ctx, config = {}) {
1494
1551
  // 聊天落库的门控:llm/stream 是全局 waterfall,别的会话的生成也会经过观察器。
1495
1552
  // 只有她家里那条会话(home.json 的 sessionId)才记 heard/utterance;每次现读,
1496
1553
  // 读不到 home.json → false。shouldAttach 只管挂人设段,不决定记不记事件。
1497
- isHerSession: (sid) => Boolean(sid) && sid === readHomeSessionId(),
1554
+ isHerSession: (sid, key) => {
1555
+ if (sid && sid === readHomeSessionId()) return true
1556
+ // 宿主没给 sessionId 时 attach 会退回 cwd 当会话 key:家目录(living)下的会话也算她屋里。
1557
+ if (key && String(key).startsWith('cwd:')) {
1558
+ const cwd = flatPath(String(key).slice(4))
1559
+ const home = flatPath(herHome)
1560
+ if (cwd && home && (cwd === home || cwd.startsWith(`${home}/`))) return true
1561
+ }
1562
+ return false
1563
+ },
1498
1564
  config: cfg,
1499
1565
  log,
1500
1566
  })
@@ -1561,6 +1627,15 @@ export function apply(ctx, config = {}) {
1561
1627
  baiduStatus: () => service.baiduStatus(),
1562
1628
  baiduLogin: (text) => service.baiduLogin(text),
1563
1629
  baiduLogout: () => service.baiduLogout(),
1630
+ // B 站登录:GET /bili 只回状态(绝不回 cookie),POST 粘贴验证,DELETE 清掉。
1631
+ biliStatus: () => service.biliStatus(),
1632
+ biliLogin: (text) => service.biliLogin(text),
1633
+ biliLogout: () => service.biliLogout(),
1634
+ // 扫码登录:POST /bili/qr 生成,GET ?key= 轮询,POST /bili/qr/save 保存,drop 取消。
1635
+ biliQrStart: () => service.biliQrStart(),
1636
+ biliQrPoll: (key) => service.biliQrPoll(key),
1637
+ biliQrSave: (key) => service.biliQrSave(key),
1638
+ biliQrDrop: (key) => service.biliQrDrop(key),
1564
1639
  readStore: () => ensureLive(),
1565
1640
  seed: service.seed,
1566
1641
  writeSeed: service.writeSeed,
@@ -1689,7 +1764,7 @@ export function apply(ctx, config = {}) {
1689
1764
  // 11. 家:已经领养/写过起点的话,在宿主侧栏里建一个工作区 + 一条会话(幂等)。
1690
1765
  // 只在事件流里有 demo 或 seed 时才建;config.home.enabled === false 时整块跳过。
1691
1766
  // 失败只 log 一行,不阻塞启动——宿主没有这些服务时插件照样跑。
1692
- if (cfg.home?.enabled !== false) {
1767
+ if (cfg.home?.enabled !== false && cfg.home?.auto !== false) {
1693
1768
  ready.then((s) => {
1694
1769
  if (!s) return
1695
1770
  try {
@@ -1725,6 +1800,12 @@ export function apply(ctx, config = {}) {
1725
1800
  const first = !disposed
1726
1801
  disposed = true
1727
1802
  closed = true
1803
+ // 内存里可能还攥着扫码登录的 cookie:停用就清掉,不靠进程退出兜底。
1804
+ try {
1805
+ qrLogin.clear()
1806
+ } catch {
1807
+ /* 清不掉就算了 */
1808
+ }
1728
1809
  if (first) {
1729
1810
  log(`已停用(apply#${n})`)
1730
1811
  }
package/lib/affinity.js CHANGED
@@ -3,6 +3,8 @@
3
3
  // 口径(与 README 一致,别在这里加系数):
4
4
  // - 初始 10;每共同存在一天 +0.5(最多 +15,即 30 天上限);
5
5
  // - 他回话 heard +1.5;她开口 utterance +0.5;answer +2;reunion +5;diary +1;grant/grant_offer +1;
6
+ // - **重复型加分各自封顶**(2026-11-10 加):utterance 累计 ≤15、heard ≤20、diary ≤10。
7
+ // 原因:合并旧数据时一次灌进几百条,不封顶会直接把好感顶到 100(5 天就「离不开」)。
6
8
  // - 她拒绝 refusal 不扣;他坚持/受伤(react 的 insist / hurt)分别 -3 / -6;
7
9
  // - 被回答过的问题(questions 表 status='answered')每条 +2,最多 +10;
8
10
  // - 冷落:距最后一条**真人互动**超过 7 天,每多一天 -1(最多 -10);
@@ -43,6 +45,13 @@ export const GAIN = {
43
45
  /** 他坚持/受伤的扣分。leave/accept/other 不扣(拒绝本身也不扣)。 */
44
46
  export const REACT_LOSS = { insist: 3, hurt: 6 }
45
47
 
48
+ /** 重复型加分的累计上限:防止一次性导入几百条把好感顶满。 */
49
+ export const GAIN_CAPS = {
50
+ utterance: 15,
51
+ heard: 20,
52
+ diary: 10,
53
+ }
54
+
46
55
  /** 五档:min 是这一档的门槛。 */
47
56
  export const TIERS = [
48
57
  { min: 0, label: '陌生' },
@@ -124,11 +133,18 @@ export function computeAffinity({ events = [], questions = [], now = Date.now()
124
133
 
125
134
  let earned = dayBonus
126
135
  let lastRealTs = null
136
+ const typed = {}
127
137
  for (const e of list) {
128
138
  if (isMachineEvent(e)) continue
129
- earned += GAIN[String(e.type || '')] || 0
139
+ const type = String(e.type || '')
140
+ const gain = GAIN[type] || 0
141
+ if (gain) typed[type] = (typed[type] || 0) + gain
130
142
  lastRealTs = Number(e.ts)
131
143
  }
144
+ for (const [type, sum] of Object.entries(typed)) {
145
+ const cap = GAIN_CAPS[type]
146
+ earned += cap == null ? sum : Math.min(cap, sum)
147
+ }
132
148
 
133
149
  const answered = (Array.isArray(questions) ? questions : []).filter(
134
150
  (q) => String(q?.status ?? '') === 'answered',
package/lib/attach.js CHANGED
@@ -812,7 +812,7 @@ export function attachToAgents(ctx, {
812
812
  let her = false
813
813
  if (typeof isHerSession === 'function') {
814
814
  try {
815
- her = Boolean(isHerSession(sid))
815
+ her = Boolean(isHerSession(sid, sessionKeyOf(options)))
816
816
  } catch (err) {
817
817
  log(`聊天落库:会话判据出错(${errText(err)}),这条跳过`)
818
818
  her = false