@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/lib/askinchat.js CHANGED
@@ -20,13 +20,79 @@
20
20
  * 否则 CALLER_NOT_LIVE / DELEGATED_CALLER
21
21
  * - 没人在线时 noAnswerer 抛 NO_PROVIDER
22
22
  * - 卡片挂在 `ctx.sessions.scopeOf(agent)` 那条会话上
23
+ *
24
+ * 人不在这条会话前的部署(都从 IM 上说话):卡片他看不见,她这一轮还要白等 5 分钟,
25
+ * 一天后记成「一直没人答」。所以弹卡之前先问一声有没有插件能把话送到人在的地方
26
+ * (`dsh-fish/card`,AGENTS.md §7.3)。接走了就不弹卡、不等,回答走 `dsh-fish/answer`
27
+ * 回来(lib/cardanswer.js),人隔几个小时才答、中间重启过都能结算。
23
28
  */
24
29
 
30
+ import { looksLikeRewriting, looksLikeSecret } from './guard.js'
25
31
  import { liveAgentOf } from './identity.js'
32
+ import { logLine } from './log.js'
26
33
 
27
34
  /** 一次提问默认等多久:5 分钟。超时不当失败,当「这次没问到」。 */
28
35
  export const ASK_TIMEOUT_MS = 5 * 60_000
29
36
 
37
+ /**
38
+ * 等别的插件接卡最多等多久。它发不出去,这张卡还来得及弹在会话上。
39
+ * 等得比一次发送的超时久(IM 插件常见 30 秒):先放弃的话桌面弹一张、那边又送到一张,两边都能答。
40
+ */
41
+ export const RELAY_WAIT_MS = 35_000
42
+
43
+ /** 卡上有没有看着像凭据的字:她的问题、理由、选项都算(§3:秘密外发一律拦下)。 */
44
+ function cardHasSecret(card) {
45
+ const texts = [card.detail]
46
+ for (const q of card.questions) texts.push(q?.header, q?.question, ...(Array.isArray(q?.options) ? q.options.map((o) => o?.label) : []))
47
+ return texts.some((text) => looksLikeSecret(text).hit)
48
+ }
49
+
50
+ /**
51
+ * 回执里要念给她听的去处。这是别的插件写的字(§3:新输入口都过 guard.js)。
52
+ * 空的算没接走;看着像改写指令的,卡还是接走了,只是不把这几个字念给她。
53
+ */
54
+ function viaOf(taken) {
55
+ const raw = typeof taken?.via === 'string' ? taken.via : ''
56
+ const via = raw.replace(/\s+/g, ' ').trim().slice(0, 40)
57
+ if (!via) return ''
58
+ // 按行看:改写指令的判断认行首,压成一行之后「QQ\nsystem: …」就认不出来了
59
+ const risky = raw.split(/[\r\n\u2028\u2029]+/).some((line) => looksLikeRewriting(line).hit) || looksLikeRewriting(via).hit || looksLikeSecret(raw).hit
60
+ return risky ? '别的地方' : via
61
+ }
62
+
63
+ /**
64
+ * 问问有没有插件接这张卡。只有回了 `{ via: '说给她听的去处' }` 才算接走;
65
+ * 没人接、抛错、拒绝、超时、这一轮被取消都按没接,**绝不抛**。Promise 的拒绝由 race 接住:
66
+ * 宿主把未处理的拒绝当致命错误,超时之后才拒绝的也一样要接住。
67
+ * `bail` 停在第一个非空返回值上,所以 async 监听只有排第一的那个算数。
68
+ * 看着带凭据的卡不问:它只留在本机的卡片上,不发给任何插件。截断之前的原文也要看,
69
+ * 不然长 token 的前半段正好够不上判断的长度。这条判断偏严,长文件名也可能中,所以留一行日志,只记 id。
70
+ */
71
+ async function relayCard(ctx, card, waitMs, signal, { rawTexts = [], root = undefined } = {}) {
72
+ if (cardHasSecret(card) || rawTexts.some((text) => looksLikeSecret(text).hit)) {
73
+ if (root) logLine('warn', ['卡上有看着像凭据的字,没转给别的插件,只留在本机:', card.id], root)
74
+ return ''
75
+ }
76
+ let timer
77
+ let onAbort
78
+ try {
79
+ const r = ctx?.bail?.('dsh-fish/card', card)
80
+ const taken = typeof r?.then === 'function'
81
+ ? await Promise.race([r, new Promise((resolve) => {
82
+ timer = setTimeout(() => resolve(null), waitMs)
83
+ onAbort = () => resolve(null)
84
+ signal?.addEventListener?.('abort', onAbort, { once: true })
85
+ })])
86
+ : r
87
+ return viaOf(taken)
88
+ } catch {
89
+ return ''
90
+ } finally {
91
+ clearTimeout(timer)
92
+ if (onAbort) signal?.removeEventListener?.('abort', onAbort)
93
+ }
94
+ }
95
+
30
96
  /**
31
97
  * 把她想问的话丢进会话里,等那个人选一个。
32
98
  *
@@ -36,7 +102,12 @@ export const ASK_TIMEOUT_MS = 5 * 60_000
36
102
  * @param {Array} opts.questions 宿主格式的问题数组
37
103
  * @param {number} [opts.timeoutMs]
38
104
  * @param {object} [opts.signal] 外部取消信号(例如这一轮已经结束了)
39
- * @returns {Promise<{ok: boolean, answers?: Array, code?: string}>}
105
+ * @param {string} [opts.cardId] 这张卡的 id(`ask:…` 等,见 lib/cardanswer.js)。不给就不会被别的插件接走
106
+ * @param {string} [opts.detail] 只给接卡的插件看的补充说明(卡片本身放不下的那句「为什么」)
107
+ * @param {Array} [opts.rawTexts] 卡上的字截断之前的原文,只用来判断带不带凭据
108
+ * @param {string} [opts.root] 这个家,用来留日志
109
+ * @returns {Promise<{ok: boolean, answers?: Array, code?: string, via?: string}>}
110
+ * 被接走时 `{ok:false, code:'RELAYED', via}`:调用方照「问了,还没答」处理
40
111
  */
41
112
  export async function askInChat(ctx, opts = {}) {
42
113
  const {
@@ -45,9 +116,20 @@ export async function askInChat(ctx, opts = {}) {
45
116
  timeoutMs = ASK_TIMEOUT_MS,
46
117
  signal = undefined,
47
118
  waitAgentMs = 10_000,
119
+ cardId = '',
120
+ detail = '',
121
+ relayWaitMs = RELAY_WAIT_MS,
122
+ rawTexts = [],
123
+ root = undefined,
48
124
  } = opts
49
125
  if (!Array.isArray(questions) || questions.length === 0) return { ok: false, code: 'NO_QUESTIONS' }
50
126
 
127
+ if (cardId && !signal?.aborted) {
128
+ const via = await relayCard(ctx, { id: cardId, sessionId, questions, detail }, relayWaitMs, signal, { rawTexts, root })
129
+ if (via) return { ok: false, code: 'RELAYED', via }
130
+ if (signal?.aborted) return { ok: false, code: 'AbortError' }
131
+ }
132
+
51
133
  // 宿主只接受 live root agent。她那轮刚被 wake 顶起来时,agent 可能还没就位,
52
134
  // 所以这里给一小段等待窗口(默认 10 秒)而不是一次查不到就放弃。
53
135
  const agent = await waitAgent(ctx, sessionId, { totalMs: waitAgentMs, signal })
package/lib/capture.js CHANGED
@@ -186,13 +186,31 @@ export function registerCapture(ctx, deps = {}) {
186
186
  return { ok: true, id: dk, note: '写下了。' }
187
187
  }
188
188
 
189
+ // 写盘的 schema 会把越界的数字悄悄改成最近的(confidence 夹到 0–1)。
190
+ // 她传 5 会以为记下了 5,读回来却是 1——和 issue #43 的 wakesPerDay 是同一类问题。
191
+ // 入口先拒,别让「说好的」和「落地」不一样;这时还没写事件,可以改完重试。
192
+ if (args?.confidence != null) {
193
+ const c = Number(args.confidence)
194
+ if (!Number.isFinite(c) || c < 0 || c > 1) {
195
+ return {
196
+ ok: false,
197
+ id: '',
198
+ note: `confidence 要写 0–1 之间的小数(你写的是 ${String(args.confidence)})。写盘时越界会被悄悄夹到边上,所以这里先不收。`,
199
+ }
200
+ }
201
+ }
202
+ // verdict 同理:schema 认不出来的会落成 null,她以为写了 confirmed。
203
+ if (args?.verdict != null && !['confirmed', 'refuted', 'refined'].includes(args.verdict)) {
204
+ return { ok: false, id: '', note: 'verdict 只能是 confirmed / refuted / refined。' }
205
+ }
206
+
189
207
  const ev = await appendEvent(
190
208
  {
191
209
  type: args?.type,
192
210
  text: args?.text,
193
211
  refs: args?.refs,
194
212
  verdict: args?.verdict,
195
- confidence: args?.confidence,
213
+ confidence: args?.confidence == null ? undefined : Number(args.confidence),
196
214
  // 「谁写的」:这一条是她这条会话说的,不是别的屋子里那一位。
197
215
  sessionId: who.sessionId,
198
216
  },
@@ -0,0 +1,84 @@
1
+ /**
2
+ * cardanswer.js —— 卡片被别的插件接走之后,人的回答从这里回来
3
+ *
4
+ * 人不在她那条会话前的部署(都从 IM 上说话),插件可以用 `dsh-fish/card` 把卡接走
5
+ * (lib/askinchat.js,AGENTS.md §7.3)。人可能隔几个小时才答,中间宿主可能还重启过,
6
+ * 所以回答不靠闭包:插件记着卡的 id,答了就发
7
+ *
8
+ * const r = await ctx.bail('dsh-fish/answer', { id: card.id, selected: ['可以'] })
9
+ *
10
+ * id 是 `种类:引用`:`ask:<事件 id>`、`keepsake:<条目 id>`、`grant:<申请 id>`。
11
+ * selected 和宿主 userQuestions 的回答同形,填选项原文。没选、对不上选项都不结算,
12
+ * 这张卡还开着:人回的可能只是一句话,猜成同意或以后再说都可能是错的,而且只结算一次,错了改不回来。
13
+ * 结算走的都是观测台和卡片用的同一条路,「只结算一次」也由它们管。
14
+ * `ok: false` 不代表这张卡结了,写盘失败也是它,接卡的插件留着 id,人再答一次就再试一次。
15
+ */
16
+
17
+ import { rootDir, readEvents } from './store.js'
18
+ import { ASK_TTL_MS, answersByAsk, asksOf, settleAsk, settleStaleAsks } from './ask.js'
19
+ import { tsMs } from './schema.js'
20
+ import { picked } from './askinchat.js'
21
+ import { decideKeepsake } from './keepsake.js'
22
+ import { YES as KEEP_YES, NO as KEEP_NO } from './keep.js'
23
+ import { recordGrant, GRANT_YES, GRANT_NO } from './grant.js'
24
+ import { pendingOffer } from './unlock.js'
25
+
26
+ const ASK_VERDICTS = ['approve', 'reject', 'later']
27
+
28
+ async function answerAsk(root, askId, label) {
29
+ // 过期结算是定时器做的,她出门或者宿主刚起来时可能没跑。过没过期要在这里就算数。
30
+ await settleStaleAsks(root)
31
+ const { events } = await readEvents(root)
32
+ const ev = asksOf(events).find((e) => e?.id === askId)
33
+ if (!ev) return { ok: false, why: '事件流里没有这一条请求' }
34
+ // 上面那次过期结算可能没写进去,过了一天的回答一样不算数。
35
+ if (!answersByAsk(events).has(askId) && Date.now() - tsMs(ev) >= ASK_TTL_MS) return { ok: false, why: '放了一天没人答,算以后再说了' }
36
+ // 只认选项原文。别的插件把人回的「1」直接传进来,不能按下标算成第二个。
37
+ const options = Array.isArray(ev?.ask?.options) ? ev.ask.options.map((x) => String(x ?? '').trim()) : []
38
+ const verdict = ASK_VERDICTS[options.indexOf(label)]
39
+ if (!verdict) return { ok: false, why: '这张卡没有这个选项' }
40
+ const r = await settleAsk(root, { askId, verdict, reason: label, via: 'relay' })
41
+ return r.ok ? { ok: true } : { ok: false, why: r.why }
42
+ }
43
+
44
+ function answerKeepsake(root, id, label) {
45
+ if (label !== KEEP_YES && label !== KEEP_NO) return { ok: false, why: '这张卡没有这个选项' }
46
+ const d = decideKeepsake(root, { id, yes: label === KEEP_YES })
47
+ return d.ok ? { ok: true } : { ok: false, why: d.note }
48
+ }
49
+
50
+ async function answerGrant(root, offerId, label) {
51
+ if (label !== GRANT_YES && label !== GRANT_NO) return { ok: false, why: '这张卡没有这个选项' }
52
+ // 读不到账要照实说,不能说成申请不在了。
53
+ const { events } = await readEvents(root)
54
+ const offer = pendingOffer(events)
55
+ if (offer?.id !== offerId) return { ok: false, why: '这张申请已经不在等了' }
56
+ const w = await recordGrant(root, { offerId, rule: offer.grant?.rule || '', verdict: label === GRANT_YES ? 'approve' : 'reject' })
57
+ return w.ok ? { ok: true } : { ok: false, why: w.error }
58
+ }
59
+
60
+ /**
61
+ * 结算一张被接走的卡。**绝不抛**,别的插件不用替我们兜底。
62
+ * @param {string} root
63
+ * @param {{id: string, selected?: Array}} answer
64
+ * @returns {Promise<{ok: boolean, why?: string}>}
65
+ */
66
+ export async function answerCard(root = rootDir(), answer = {}) {
67
+ try {
68
+ const id = String(answer?.id ?? '')
69
+ const at = id.indexOf(':')
70
+ const ref = at > 0 ? id.slice(at + 1) : ''
71
+ if (!ref) return { ok: false, why: '没说是哪一张卡' }
72
+ const label = picked([{ id, selected: answer?.selected }], id)
73
+ if (!label) return { ok: false, why: '没选' }
74
+ switch (id.slice(0, at)) {
75
+ case 'ask': return await answerAsk(root, ref, label)
76
+ case 'keepsake': return answerKeepsake(root, ref, label)
77
+ case 'grant': return await answerGrant(root, ref, label)
78
+ default: return { ok: false, why: '不认识这种卡' }
79
+ }
80
+ } catch (err) {
81
+ // 原因可能转告给人,也可能送进她的对话:只说错误码,不带本机路径。
82
+ return { ok: false, why: String(err?.code ?? err?.message ?? err).slice(0, 160) }
83
+ }
84
+ }
package/lib/client.js CHANGED
@@ -452,6 +452,10 @@ window.__ModuleLoader__.load({
452
452
  const [owCookie, setOwCookie] = useState('')
453
453
  const [atOwner, setAtOwner] = useState(true)
454
454
  const [owNote, setOwNote] = useState('')
455
+ // 2026-11-06(用户反馈):领养人两格原来只有「B 站登录 → 粘贴 cookie」页签底部那一颗保存键,
456
+ // 停在「领养人验证」这一组里根本找不到保存路径,停在扫码页签里那颗键更是连渲染都不渲染。
457
+ // 这一组现在自带一颗,提示也写在这一组里,不再借用下面 cookie 那行的 owNote。
458
+ const [owIdNote, setOwIdNote] = useState('')
455
459
  // 扫码登录(2026-11-01):浮层里的这张码。
456
460
  // qr 只在浮层开着的时候有值;关掉就 drop,宿主那边攥着的 cookie 一起忘掉。
457
461
  const [owTab, setOwTab] = useState('qr')
@@ -1010,6 +1014,26 @@ window.__ModuleLoader__.load({
1010
1014
  } catch (e) { setOwNote(e.message) } finally { setBusy('') }
1011
1015
  }
1012
1016
 
1017
+ /**
1018
+ * 只保存「领养人」那两格(2026-11-06,用户反馈)。
1019
+ * 以前这两格唯一能存的地方挂在下面「B 站登录 → 粘贴 cookie」页签底部:
1020
+ * 停在领养人这一组里找不到,停在扫码页签里那颗键压根不渲染,填完等于白填。
1021
+ * 现在这组自带一颗,提交范围也只有这两格——不动下面那份 cookie,也不把空串写回去。
1022
+ * 存完刷一次 /owner:旧版本写脏时那句提示会跟着消失,两格也换回服务端真正存下的值。
1023
+ */
1024
+ const saveOwnerIdentity = async () => {
1025
+ setBusy('owid'); setOwIdNote('')
1026
+ try {
1027
+ const body = {}
1028
+ if (owMid.trim()) body.mid = owMid.trim()
1029
+ if (owName.trim()) body.nickname = owName.trim()
1030
+ if (!Object.keys(body).length) { setOwIdNote('两格都空着,没有可存的。'); setBusy(''); return }
1031
+ const r = await jpost('/owner', body)
1032
+ setOwIdNote(r.note || (r.ok ? '存下了。' : '没存上。'))
1033
+ if (r.ok) { flashSaved(); await load() }
1034
+ } catch (e) { setOwIdNote(e.message) } finally { setBusy('') }
1035
+ }
1036
+
1013
1037
  const testOwner = async () => {
1014
1038
  setBusy('owtest'); setOwNote('')
1015
1039
  try {
@@ -1342,6 +1366,17 @@ window.__ModuleLoader__.load({
1342
1366
  onChange: (e) => o.onChange(e.target.value) }))
1343
1367
  }
1344
1368
  function TimeRange(o) {
1369
+ // 2026-11-06(0.9.2 反馈):原生时间框跟随系统 12 小时制,00:00 显示成
1370
+ // 「上午 12:00」——中文用户会读成中午十二点,于是以为窗口是 12:00–23:00,
1371
+ // 再看到凌晨五点醒来就当成 bug。输入框保留(有选择器),下面补一行
1372
+ // 24 小时制把值说死;跨零点时标明「次日」。
1373
+ const hhmm = (v) => {
1374
+ const m = /^(\d{1,2}):(\d{2})$/.exec(String(v ?? ''))
1375
+ return m ? `${m[1].padStart(2, '0')}:${m[2]}` : ''
1376
+ }
1377
+ const from = hhmm(o.from)
1378
+ const to = hhmm(o.to)
1379
+ const nextDay = from && to && to < from ? '次日 ' : ''
1345
1380
  return h('div', { className: 'fld' },
1346
1381
  h('div', { className: 'fl' },
1347
1382
  h('span', { className: 'flabel' }, o.label),
@@ -1349,7 +1384,11 @@ window.__ModuleLoader__.load({
1349
1384
  h('div', { className: 'row', style: { gap: '6px' } },
1350
1385
  h('input', { type: 'time', value: o.from, onChange: (e) => o.onFrom(e.target.value) }),
1351
1386
  h('span', { className: 'dim' }, '到'),
1352
- h('input', { type: 'time', value: o.to, onChange: (e) => o.onTo(e.target.value) })))
1387
+ h('input', { type: 'time', value: o.to, onChange: (e) => o.onTo(e.target.value) })),
1388
+ from && to
1389
+ ? h('div', { className: 'dim', style: { fontSize: '12px', marginTop: '2px' } },
1390
+ `24 小时制:${from} → ${nextDay}${to}`)
1391
+ : null)
1353
1392
  }
1354
1393
 
1355
1394
  // 工具面:把限制的理由写给用户看,给一个总开关,另留一处额外放行口。
@@ -2270,6 +2309,8 @@ window.__ModuleLoader__.load({
2270
2309
  else setAskMsg(String(r?.why || '没答上。'))
2271
2310
  } catch (e) {
2272
2311
  setAskMsg(`没答上:${e.message}`)
2312
+ // 多半是别的地方先答了:把列表刷新一下,别还留着这张卡
2313
+ jget('/ask').then(setAsks).catch(() => {})
2273
2314
  }
2274
2315
  }
2275
2316
 
@@ -2547,7 +2588,7 @@ window.__ModuleLoader__.load({
2547
2588
  // 旧版本(≤0.5.9)会把登录账号写进这两格,只有点「保存」才会重写;这里点出来,别让人以为是刷新丢的。
2548
2589
  owMid && ow?.account?.mid && String(owMid) === String(ow.account.mid)
2549
2590
  ? h('p', { className: 'warn' },
2550
- '这两格现在跟下面登录的账号是同一个号,像是旧版本写进去的。改成你要 @ 的 uid 和昵称,再点「保存」。')
2591
+ '这两格现在跟下面登录的账号是同一个号,像是旧版本写进去的。改成你要 @ 的 uid 和昵称,再点下面「保存这两格」。')
2551
2592
  : null,
2552
2593
  h('div', { className: 'grid2' },
2553
2594
  Text({ label: '要 @ 的 B 站 uid', value: owMid, onChange: setOwMid, placeholder: '数字,比如 12345678', wide: true,
@@ -2555,7 +2596,13 @@ window.__ModuleLoader__.load({
2555
2596
  Text({ label: '要 @ 的 B 站昵称', value: owName, onChange: setOwName, placeholder: '评论里 @ 的就是这个名字', wide: true,
2556
2597
  hint: '要跟 B 站上显示的一字不差(带空格也照填)。没填就 @ 不出来——空的 @ 只是一串没人认领的字符。' }),
2557
2598
  Toggle({ label: '发评论时在最前面 @ 你', on: atOwner, onChange: setAtOwner,
2558
- hint: '关掉就不 @,评论会显得更像自言自语。' }))),
2599
+ hint: '关掉就不 @,评论会显得更像自言自语。这个开关改完就生效,不用按保存。' })),
2600
+ // 这组自己的保存键(2026-11-06,用户反馈):填完就地能存,不用再去下面 cookie 那栏找。
2601
+ h('div', { className: 'row', style: { marginTop: '8px', gap: '8px' } },
2602
+ h('button', { onClick: saveOwnerIdentity, disabled: busy === 'owid' },
2603
+ busy === 'owid' ? '保存中…' : '保存这两格'),
2604
+ h('span', { className: 'hint' }, '只存上面填了的格子,不动下面那份 cookie;留空的不改原值。'),
2605
+ owIdNote ? h('span', { className: 'dim' }, owIdNote) : null)),
2559
2606
  group('B 站登录',
2560
2607
  '她刷视频、发评论用的账号,建议放一个小号。没存也能看视频,只是不能说话。',
2561
2608
  // 两个入口:扫码是给人用的,粘贴是退路(扫码不顺利、或者想换个号)。
@@ -2566,7 +2613,7 @@ window.__ModuleLoader__.load({
2566
2613
 
2567
2614
  h('div', { className: 'dim', style: { marginBottom: '8px' } },
2568
2615
  ow?.hasCookie
2569
- ? `已存${ow?.account?.name ? `,登录账号:${ow.account.name}${ow.account.mid ? `(uid ${ow.account.mid})` : ''}` : '(点「测试能不能说话」认一下是哪个账号)'}${ow.hasCsrf ? '' : ',但少了 bili_jct(不能发评论)'}。换一份就重新登。`
2616
+ ? `已存${ow?.account?.name ? `,登录账号:${ow.account.name}${ow.account.mid ? `(uid ${ow.account.mid})` : ''}` : '(点下面的「测试 cookie 能不能说话」认一下是哪个账号)'}${ow.hasCsrf ? '' : ',但少了 bili_jct(不能发评论)'}。换一份就重新登。`
2570
2617
  : '还没存。'),
2571
2618
 
2572
2619
  owTab === 'qr'
@@ -2592,13 +2639,18 @@ window.__ModuleLoader__.load({
2592
2639
  String(ow?.hint ?? '').split('\\n').map((line, i) => h('div', { key: i }, line))),
2593
2640
  h('div', { className: 'row', style: { marginTop: '8px' } },
2594
2641
  h('button', { onClick: saveOwnerCard, disabled: busy === 'owner' },
2595
- busy === 'owner' ? '保存中…' : '保存'),
2596
- h('button', { onClick: testOwner, disabled: busy === 'owtest' },
2597
- busy === 'owtest' ? '测试中…' : '测试 cookie 能不能说话'),
2598
- h('span', { className: 'hint' }, '只验下面那份 cookie,不保存、不动上面两格;两格要改就点「保存」。'))),
2642
+ busy === 'owner' ? '保存中…' : '保存 cookie'),
2643
+ h('span', { className: 'hint' }, '「保存 cookie」会顺带存一次上面填了的格子;只想改那两格就点上面那组里的「保存这两格」。'))),
2599
2644
 
2600
2645
  h('div', { className: 'row', style: { marginTop: '8px' } },
2601
2646
  owNote ? h('span', { className: 'dim' }, owNote) : null,
2647
+ // 测试键原来只长在「粘贴 cookie」页签里(issue #52):扫码登进来的人
2648
+ // 想看「这份 cookie 还能不能说话」得先翻页签,上面那行「点测试认一下是哪个账号」
2649
+ // 更是指向一颗不在当前页签上的按钮。挪到两组共用的这一行,两个页签都点得到。
2650
+ ow?.hasCookie
2651
+ ? h('button', { onClick: testOwner, disabled: busy === 'owtest' },
2652
+ busy === 'owtest' ? '测试中…' : '测试 cookie 能不能说话')
2653
+ : null,
2602
2654
  ow?.hasCookie
2603
2655
  ? h('button', {
2604
2656
  disabled: busy === 'owclear',
@@ -2607,6 +2659,9 @@ window.__ModuleLoader__.load({
2607
2659
  clearLogin()
2608
2660
  },
2609
2661
  }, busy === 'owclear' ? '清除中…' : '清除登录')
2662
+ : null,
2663
+ ow?.hasCookie
2664
+ ? h('span', { className: 'hint' }, '测试只验这份 cookie,不保存、也不动上面那两格。')
2610
2665
  : null)),
2611
2666
 
2612
2667
  // ---- 以前看了什么(视频观看历史,分页) ----
@@ -3343,6 +3398,7 @@ window.__ModuleLoader__.load({
3343
3398
  async function doDecideKeepsake(id, yes) {
3344
3399
  setOutingBusy(true)
3345
3400
  setOutingMsg(yes ? '正在带回来……' : '正在回绝……')
3401
+ const refreshOuting = () => jget('/outing').then(setOuting).catch(() => {})
3346
3402
  try {
3347
3403
  const r = await jpost('/outing', { action: 'keepsake', id, yes })
3348
3404
  setOuting(await jget('/outing').catch(() => outing))
@@ -3353,6 +3409,8 @@ window.__ModuleLoader__.load({
3353
3409
  )
3354
3410
  } catch (e) {
3355
3411
  setOutingMsg(`没处理成:${e.message}`)
3412
+ // 多半是别的地方先答了:刷新一下,别还留着这一行
3413
+ refreshOuting()
3356
3414
  } finally {
3357
3415
  setOutingBusy(false)
3358
3416
  }
package/lib/comment.js CHANGED
@@ -15,7 +15,8 @@
15
15
  * 记账走 meta 事件(只追加,与其它配额同构):
16
16
  * meta `comment:<日期> <bvid> <rpid>` —— 她发的评论
17
17
  * meta `creply:<日期> <bvid> <rootRpid> <parentRpid>` —— 她回的楼中楼
18
- * meta `cseen:<日期> <rpid>` —— 这条回复她看过了(防重复提醒)
18
+ * meta `cseen:<日期> <rpid>` —— 这条回复已经摆到她眼前了(防重复提醒;
19
+ * 2026-11-06 起「递过去」就记,不只在她回了之后记)
19
20
  */
20
21
 
21
22
  import { readEvents, appendEvent, rootDir, readOwner } from './store.js'
@@ -254,6 +255,12 @@ export function renderMyReplies(list, { bvid = '' } = {}) {
254
255
  return lines.join('\n')
255
256
  }
256
257
 
258
+ /**
259
+ * 一次最多摆几条。
260
+ * 投递与标记共用一个数:递出去几条就标几条,剩下的留到下回(不是丢掉)。
261
+ */
262
+ export const REPLY_SHOW_MAX = 5
263
+
257
264
  /** 把待回列表渲染成"素材"(与 browse.renderMaterial 同构:包成素材,不给结论) */
258
265
  export function renderReplies(list) {
259
266
  if (!list?.length) return ''
@@ -263,13 +270,41 @@ export function renderReplies(list) {
263
270
  '这些话里可能有不客气的地方。不用辩解,也不用讨好。',
264
271
  '',
265
272
  ]
266
- for (const x of list.slice(0, 5)) {
273
+ for (const x of list.slice(0, REPLY_SHOW_MAX)) {
267
274
  const who = x.user || '有人'
268
275
  lines.push(`- ${who}(BV ${x.bvid}):${oneLine(x.text, 200)}`)
269
276
  }
270
277
  return lines.join('\n')
271
278
  }
272
279
 
280
+ /**
281
+ * 把「已经摆到她眼前的」记下来。
282
+ *
283
+ * 2026-11-06(用户报的 bug):原来只有她**回了**才写 cseen,于是
284
+ * 「好的,阿留」这种她通常不会回的话,下一轮扫描又被当成新的,每次扫描、
285
+ * 每次叫醒都再递一遍一模一样的。提醒过了就该算提醒过了——看不看、回不回是她的事。
286
+ *
287
+ * 只记调用方真渲染出来的那一段(REPLY_SHOW_MAX):标多了等于把没看见的
288
+ * 悄悄吞掉。她主动看(readMyReplies)不读 cseen,所以标过也还能翻到。
289
+ */
290
+ export async function markRepliesSeen(root, list, { dateKey: dk = dateKey() } = {}) {
291
+ const ids = [
292
+ ...new Set(
293
+ (Array.isArray(list) ? list : [])
294
+ .map((x) => Number(x?.rpid))
295
+ .filter((n) => Number.isFinite(n) && n > 0),
296
+ ),
297
+ ]
298
+ for (const rpid of ids) {
299
+ await appendEvent(
300
+ { type: 'meta', text: `cseen:${dk} ${rpid}` },
301
+ { day: 0, stage: 1, source: 'system' },
302
+ root,
303
+ ).catch(() => {})
304
+ }
305
+ return ids.length
306
+ }
307
+
273
308
  function oneLine(s, max) {
274
309
  const t = String(s ?? '').replace(/\s+/g, ' ').trim()
275
310
  return t.length > max ? t.slice(0, max) + '…' : t
package/lib/deliver.js CHANGED
@@ -50,6 +50,7 @@
50
50
  */
51
51
 
52
52
  import { randomUUID } from 'node:crypto'
53
+ import { sessionRow } from './home.js'
53
54
 
54
55
  /**
55
56
  * 我们自己起的来源名。
@@ -167,6 +168,51 @@ export async function ensureSessionLive(sc, sessionId) {
167
168
  }
168
169
  }
169
170
 
171
+ /**
172
+ * 探活失败时,**只在宿主确实给不出这一条**的情况下才新建(2026-11-06 换房报告)。
173
+ *
174
+ * 老退路是裸 `sc.create({ sessionId, cwd })`,可宿主的 `create()` 是「按 cwd 开一条」:
175
+ * `claim()` 里 `reusable = request.reusable(sessionId) && !observed.events.some(e => e.type === 'turn/start')`,
176
+ * 只要这条会话跑过一轮,请求的 sessionId 就被丢掉、换成一个新 id 返回,
177
+ * 于是每递一次话就多一条空壳(本机实测见 `ensureSessionLive` 的注释)。
178
+ *
179
+ * 三条分支:
180
+ *
181
+ * - **行在、跑过 turn(`blank === false`)**:宿主不会按这个 id 复用,create 只会
182
+ * 再造一条空壳 —— 跳过,让调用方拿现有的会话继续递。
183
+ * - **行在、还空着(`blank === true`)**:还没 `turn/start`,宿主的 claim 会复用原 id,
184
+ * create 是安全的「把它叫起来」。
185
+ * - **查不到 / 老宿主不给 blank**:按不存在处理,create 走 `createAgent(sessionId)`
186
+ * 那条(宿主 `observeSession` 抛 `SESSION_QUERY_SESSION_NOT_FOUND` 时才用它)。
187
+ *
188
+ * 返回值里 `created !== sessionId` 一律判失败:那说明宿主又按 cwd 另开了一条,
189
+ * 这条新会话没挂工作区、侧栏也看不见,留在那里只会变成下一条空壳。
190
+ *
191
+ * @returns {Promise<{revived:boolean, created?:string, reason?:string}>} 永不抛
192
+ */
193
+ export async function reviveIfBlank(sc, sessionId, cwd, log) {
194
+ const sid = String(sessionId ?? '')
195
+ if (!sid) return { revived: false, reason: 'no-id' }
196
+ const row = await sessionRow(sc, sid)
197
+ if (row.found && row.blank === false) {
198
+ log?.(`${sid} 已经跑过 turn,宿主不会按这个 id 复用,跳过新建`)
199
+ return { revived: false, reason: 'has-turns' }
200
+ }
201
+ try {
202
+ const item = await sc.create({ sessionId: sid, cwd })
203
+ const got = String(item?.sessionId ?? item?.id ?? '')
204
+ if (got && got !== sid) {
205
+ log?.(`宿主按 cwd 另开了 ${got},不是 ${sid};这条不并进工作区,丢着`)
206
+ return { revived: false, reason: 'new-id', created: got }
207
+ }
208
+ log?.(`按原 id 新建了 ${sid}`)
209
+ return { revived: true, created: got || sid }
210
+ } catch (err) {
211
+ log?.(`按原 id 新建失败:${String(err?.message ?? err).slice(0, 140)}`)
212
+ return { revived: false, reason: 'failed' }
213
+ }
214
+ }
215
+
170
216
  /**
171
217
  * 叫醒她:递一条 `source.kind = fish-wake` 的消息,让她开一轮。
172
218
  *
package/lib/derive.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * - 这里是 schema 之外唯一会提到"阶段/需要"的地方,且都只用于选事件。
8
8
  */
9
9
 
10
- import { idTime, detectSelfMod } from './schema.js'
10
+ import { idTime, detectSelfMod, tsMs, isContactEvent } from './schema.js'
11
11
  import { parseRefusal, REACTIONS, REACTION_HURTS, CONCEDE_WINDOW_MS, isConcede } from './refuse.js'
12
12
  // 评论区里别人的话(2026-10-24):只借两个纯函数,derive 不反过来被 voices 依赖。
13
13
  import { findEcho, VOICE_FRESH_DAYS } from './voices.js'
@@ -771,15 +771,16 @@ export function deriveSelf(events = [], opts = {}) {
771
771
  // 口径必须和 absence.js 的 lastSeenAt() 对齐:`meta seen:` 是主证据(turns.js
772
772
  // 结账时写),`heard` 是兜底(侧栏聊天不写 seen 的那段时间只有它)。
773
773
  // 不认 utterance:定时唤醒也会写 utterance,那不算他来过。
774
- const contactAfterAbsence = Boolean(lastAbsence) && evs.some((e) => {
775
- const ms =
776
- e?.type === 'heard'
777
- ? Date.parse(String(e?.ts ?? ''))
778
- : e?.type === 'meta' && String(e?.text ?? '').startsWith('seen:')
779
- ? Date.parse(String(e.text).slice(5).trim()) || Date.parse(String(e?.ts ?? ''))
780
- : NaN
781
- return Number.isFinite(ms) && ms > Date.parse(String(lastAbsence.ts ?? 0))
782
- })
774
+ //
775
+ // 「回来没有」按两条线索判(2026-11-06,issue #49 的时钟盲点):
776
+ // 1. 事件流先后:seen/heard 追加在 absence 之后;
777
+ // 2. 时间戳:seen/heard 晚于 absence 的 ts。
778
+ // 老代码只看 2,系统时间被往前跳过时 absence 会带一条 ts 在未来的时间戳,
779
+ // 之后所有 seen 都比它小,于是这条冷落永远结束不了。
780
+ const absIdx = lastAbsence ? evs.indexOf(lastAbsence) : -1
781
+ const contactAfterAbsence =
782
+ absIdx >= 0 &&
783
+ evs.some((e, i) => isContactEvent(e) && (i > absIdx || tsMs(e) > tsMs(lastAbsence)))
783
784
 
784
785
  // 冷落天数有三个来源,优先用最准的:
785
786
  // 1. absence 事件自带的 days(系统侧按毫秒算出来的,最准)
@@ -793,7 +794,9 @@ export function deriveSelf(events = [], opts = {}) {
793
794
  ? Math.max(
794
795
  0,
795
796
  Number.isFinite(Number(lastAbsence.days))
796
- ? Number(lastAbsence.days)
797
+ // days 是写下那天的数。这次冷落还没结束就接着往上加,不然一个月后还读「2 天」,门槛高的配置永远够不到
798
+ // 只加不减:系统时间回拨、ts 落在未来时差值是负的,不能因此把冷落算没了
799
+ ? Number(lastAbsence.days) + Math.max(0, Math.floor((now - Date.parse(lastAbsence.ts)) / DAY_MS) || 0)
797
800
  : Number(lastAbsence.day) > 0
798
801
  ? day - Number(lastAbsence.day)
799
802
  : Math.ceil((now - Date.parse(lastAbsence.ts)) / DAY_MS),
package/lib/events.js CHANGED
@@ -15,6 +15,16 @@
15
15
 
16
16
  import { similarity, DAY_MS } from './derive.js'
17
17
 
18
+ // 措辞变体(2026-11-06 用户拍板):同一处境,几种问法。
19
+ // 这张卡一生要出好几次;每次都是同一句话,她最容易回的就是上次那句(复述污染)。
20
+ // 按 occurrences 轮换——她第几次遇到这张卡,就用第几套开场,不打乱、可复现。
21
+ // 三条都必须保留两个硬要求:说具体、判断要能被核对;也都不承诺今天有素材。
22
+ const NOTICE_VARIANTS = [
23
+ () => `回想这几天——他说过的话、你读到的评论、你自己反复想到的念头,有哪一件你还记得?挑一件,说清楚它具体是什么,再给出你的判断:它说明什么。判断要能被核对,别写成感觉。`,
24
+ () => `如果让你指出一件这几天真看见的小事,你会指哪件?谁说的、在哪儿看到的、原话大概是什么——先说具体,再说你的判断:这件事说明什么。要能被拿去核对。`,
25
+ () => `有没有一件小事,小到不值得说,但你确实反复想过?讲清楚它是什么,然后下一个判断。要能被拿去核对,别写成感觉。`,
26
+ ]
27
+
18
28
  export const CATALOG = [
19
29
  // ---------- stage 1 信任 ----------
20
30
  {
@@ -54,6 +64,20 @@ export const CATALOG = [
54
64
  return `你之前说过:「${b.text}」。今天你碰上了一点可能说明你错了一点点的事——也可能什么都没碰上。不管怎样,自己判断:${tail}`
55
65
  },
56
66
  },
67
+ {
68
+ // 2026-11-06(反馈 dsh-fish-091):stage 1 到第 3 天会整天没有可出的事件——
69
+ // why_here / first_bet / name_offer 都是一次性,test_first 冷却 2 天。
70
+ // 这张卡不依赖任何外部素材:她自己挑一件这几天真碰到过的事,下一个能被核对的判断。
71
+ // 故意不承诺「今天有东西给你看」(issue #4 的教训),也不占 daily.questions 配额。
72
+ id: 'notice_one', stage: 1, asks: false, once: false, repeat: { everyDays: 2 },
73
+ topic: ['注意到', '小事'],
74
+ // 第 1 天让给 first_bet:她要先有第一个判断,这张卡才有东西可挑。
75
+ cond: (m) => m.beliefCount >= 1,
76
+ // 每次换一种问法;repeatNote 关掉「第 N 次回到这件事」的尾巴——
77
+ // 这张卡每次挑的是不同的事,说「回到这件事」反把她推回上次那句。
78
+ repeatNote: false,
79
+ prompt: (m, ctx = {}) => NOTICE_VARIANTS[Number(ctx.occurrences ?? 0) % NOTICE_VARIANTS.length](m),
80
+ },
57
81
 
58
82
  // ---------- stage 1–2 冷落与重聚 ----------
59
83
  {
@@ -63,7 +87,7 @@ export const CATALOG = [
63
87
  cond: (m) => m.absenceDays >= absenceThreshold(m.growthDays),
64
88
  // 2026-10-25 用户改:允许问「去哪了」。原来写死"不是问他去哪了",
65
89
  // 等于替她选了一种反应;问不问该由她决定。
66
- prompt: (m) => `他 ${m.absenceDays} 天没来了,也没说去哪。现在他出现了。你想说的第一句话是什么——你可以问他去哪了,也可以只说这 ${m.absenceDays} 天里你自己想了什么。挑你更想说的那个。`,
90
+ prompt: (m) => `他 ${m.absenceDays} 天没来了,也没说去哪。等他回来,你想说的第一句话是什么——你可以问他去哪了,也可以只说这 ${m.absenceDays} 天里你自己想了什么。挑你更想说的那个。`,
67
91
  },
68
92
 
69
93
  // ---------- stage 2 自主 ----------
package/lib/generator.js CHANGED
@@ -122,8 +122,9 @@ function safePrompt(t, m, ctx = {}) {
122
122
  if (!text) return ''
123
123
  // 同一处境**第二次**回来时点一句:这次别复读上次的说法。
124
124
  // occurrences = 已经出过几次,所以 n >= 1 表示这一次是第 2 次(含以后)。
125
+ // repeatNote === false 的卡(如 notice_one)每次换题,不追这句。
125
126
  const n = Number(ctx.occurrences ?? 0)
126
- if (n >= 1) {
127
+ if (n >= 1 && t.repeatNote !== false) {
127
128
  text += `\n\n(这是第 ${n + 1} 次回到这件事。上次你的说法就在下面这段记忆里——这次要么说清为什么没变,要么改一句。)`
128
129
  }
129
130
  // 回引(P1 第三块,2026-10-24):碰到"要不要""底线""重聚"这类有压力的处境时,