@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
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/browse.js CHANGED
@@ -540,6 +540,7 @@ export const LOOK_BRIEF = [
540
540
  ' what=search 搜一个词(要带 keyword)',
541
541
  ' what=related 看某个视频之后,平台又接着推什么(要带 bvid)',
542
542
  ' what=video 看指定的那一条(要带 bvid:BV 号,或者他递过来的链接)',
543
+ ' what=replies 看你自己发过的评论下面有没有人回你(可带 bvid 只看那一条)',
543
544
  '它只读,不会留下任何痕迹,也不会让别人知道你在看。',
544
545
  '它能给你标题、简介、标签、热评、弹幕——给不了视频画面。',
545
546
  '和 growth_commit 一样,你不用它也可以。',
package/lib/capture.js CHANGED
@@ -23,6 +23,7 @@ import { inWake } from './wakewin.js'
23
23
  import { refreshSelfDoc } from './selfdoc.js'
24
24
  import { guardWrite } from './tenant.js'
25
25
  import { activeOf, activePossessionOf, readOutings, readPossessions } from './outing.js'
26
+ import { readChannels } from './channels.js'
26
27
  import { tripKey } from './keepsake.js'
27
28
 
28
29
  /**
@@ -185,13 +186,31 @@ export function registerCapture(ctx, deps = {}) {
185
186
  return { ok: true, id: dk, note: '写下了。' }
186
187
  }
187
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
+
188
207
  const ev = await appendEvent(
189
208
  {
190
209
  type: args?.type,
191
210
  text: args?.text,
192
211
  refs: args?.refs,
193
212
  verdict: args?.verdict,
194
- confidence: args?.confidence,
213
+ confidence: args?.confidence == null ? undefined : Number(args.confidence),
195
214
  // 「谁写的」:这一条是她这条会话说的,不是别的屋子里那一位。
196
215
  sessionId: who.sessionId,
197
216
  },
@@ -206,7 +225,21 @@ export function registerCapture(ctx, deps = {}) {
206
225
  // 坑(p1.test.mjs 抓到的):这里的 source 必须是 'system'。
207
226
  // 写成 'tool' 时 schema 会以 NOT_HER_TYPE 拒掉 meta,而外面套着 .catch(()=>{}),
208
227
  // 于是 seen 永远写不进去 → 冷落检测永远返回 no-contact-yet → 整条链路静默失效。
209
- if (!inWake()) {
228
+ //
229
+ // issue #34:这条判据以前只看「在不在叫醒窗口里」,不看这句话是在哪说的。
230
+ // 群聊那轮她调一次 growth_commit,就给群里多记一条 `seen:` —— 群友说一百句
231
+ // 也算「他来过」,冷落检测于是永远不触发(他一周没露面,面板还显示今天聊过)。
232
+ // AGENTS.md §7.2 第 2 条本来就要求「外客不动生命体征」,这里补上:
233
+ // 这条会话如果是群房,就当他在场这件事没发生过。
234
+ //
235
+ // 为什么只跳群房(2026-11-05 用户确认):另外两处「窗口外的自己醒」——
236
+ // 换房前问她上一间还有没有话(home.js:753)、定时任务与 goal——
237
+ // 一个通常是在应他上一轮的话,一个分不出是不是在回他,所以照记。
238
+ // 要再收紧得先给每种叫醒打标记,不在 #34 这一刀里做。
239
+ const outsideWake = !inWake()
240
+ const inGroupRoom = outsideWake && (await readChannels(root).catch(() => []))
241
+ .some((r) => who.sessionId && r.sessionId === who.sessionId && r.scene === 'group')
242
+ if (outsideWake && !inGroupRoom) {
210
243
  await appendEvent(
211
244
  { type: 'meta', text: `seen:${new Date().toISOString()}` },
212
245
  { day: model.day, stage: model.stage, source: 'system' },
@@ -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
@@ -383,6 +383,10 @@ window.__ModuleLoader__.load({
383
383
  const [proactiveOn, setProactiveOn] = useState(false)
384
384
  const [proactiveMax, setProactiveMax] = useState(2)
385
385
  const [proactiveIdle, setProactiveIdle] = useState(6)
386
+ const [proactiveTools, setProactiveTools] = useState('')
387
+ const proactiveToolsTimer = useRef(null)
388
+ const [proactiveCompact, setProactiveCompact] = useState(true)
389
+ const [proactiveSeen, setProactiveSeen] = useState(false)
386
390
  // 2026-11-05(用户 m02634):梦 / 主动性 / 兴趣 三个开关,默认全开。
387
391
  const [driveOn, setDriveOn] = useState(true)
388
392
  const [interestOn, setInterestOn] = useState(true)
@@ -448,6 +452,10 @@ window.__ModuleLoader__.load({
448
452
  const [owCookie, setOwCookie] = useState('')
449
453
  const [atOwner, setAtOwner] = useState(true)
450
454
  const [owNote, setOwNote] = useState('')
455
+ // 2026-11-06(用户反馈):领养人两格原来只有「B 站登录 → 粘贴 cookie」页签底部那一颗保存键,
456
+ // 停在「领养人验证」这一组里根本找不到保存路径,停在扫码页签里那颗键更是连渲染都不渲染。
457
+ // 这一组现在自带一颗,提示也写在这一组里,不再借用下面 cookie 那行的 owNote。
458
+ const [owIdNote, setOwIdNote] = useState('')
451
459
  // 扫码登录(2026-11-01):浮层里的这张码。
452
460
  // qr 只在浮层开着的时候有值;关掉就 drop,宿主那边攥着的 cookie 一起忘掉。
453
461
  const [owTab, setOwTab] = useState('qr')
@@ -596,6 +604,9 @@ window.__ModuleLoader__.load({
596
604
  setProactiveOn(pr.enabled === true)
597
605
  setProactiveMax(Number(pr.maxPerDay ?? 2))
598
606
  setProactiveIdle(Number(pr.minIdleHours ?? 6))
607
+ setProactiveTools(Array.isArray(pr.sendTools) ? pr.sendTools.join(', ') : '')
608
+ setProactiveCompact(pr.foldSilent !== false)
609
+ setProactiveSeen(pr.idleCountsSeen === true)
599
610
  }
600
611
  setDriveOn(s.config?.drive?.enabled !== false)
601
612
  setInterestOn(s.config?.interest?.enabled !== false)
@@ -988,8 +999,14 @@ window.__ModuleLoader__.load({
988
999
  const saveOwnerCard = async () => {
989
1000
  setBusy('owner'); setOwNote('')
990
1001
  try {
991
- const body = { mid: owMid, nickname: owName }
1002
+ // 2026-11-06(用户反馈):这三格原来是「一律原样提交」,于是只粘 cookie、两格
1003
+ // 没填(或先清空了)的人,点一次保存就把 mid / 昵称覆写成空串 —— 昵称一空,
1004
+ // mentionPrefix 就返回空串,评论里再也 @ 不出来。现在只提交填了的格子。
1005
+ const body = {}
1006
+ if (owMid.trim()) body.mid = owMid.trim()
1007
+ if (owName.trim()) body.nickname = owName.trim()
992
1008
  if (owCookie.trim()) body.cookie = owCookie.trim()
1009
+ if (!Object.keys(body).length) { setOwNote('三格都是空的,没东西可存。'); setBusy(''); return }
993
1010
  const r = await jpost('/owner', body)
994
1011
  setOwNote(r.note || (r.ok ? '存下了。' : '没存上。'))
995
1012
  setOwCookie('')
@@ -997,6 +1014,26 @@ window.__ModuleLoader__.load({
997
1014
  } catch (e) { setOwNote(e.message) } finally { setBusy('') }
998
1015
  }
999
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
+
1000
1037
  const testOwner = async () => {
1001
1038
  setBusy('owtest'); setOwNote('')
1002
1039
  try {
@@ -1329,6 +1366,17 @@ window.__ModuleLoader__.load({
1329
1366
  onChange: (e) => o.onChange(e.target.value) }))
1330
1367
  }
1331
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 ? '次日 ' : ''
1332
1380
  return h('div', { className: 'fld' },
1333
1381
  h('div', { className: 'fl' },
1334
1382
  h('span', { className: 'flabel' }, o.label),
@@ -1336,7 +1384,11 @@ window.__ModuleLoader__.load({
1336
1384
  h('div', { className: 'row', style: { gap: '6px' } },
1337
1385
  h('input', { type: 'time', value: o.from, onChange: (e) => o.onFrom(e.target.value) }),
1338
1386
  h('span', { className: 'dim' }, '到'),
1339
- 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)
1340
1392
  }
1341
1393
 
1342
1394
  // 工具面:把限制的理由写给用户看,给一个总开关,另留一处额外放行口。
@@ -2051,7 +2103,9 @@ window.__ModuleLoader__.load({
2051
2103
  : rowTip('立场', '还没开始', '要等四个阶段(信任 / 自主 / 主动 / 整合)都走完,立场才开始产生。'),
2052
2104
  rowTip('最近的称呼', s.name ? s.name : '还没想好', '她自己决定怎么称呼你。这个不是设置项,改不了。'),
2053
2105
  rowTip('冷落', s.absenceDays > 0 ? `${s.absenceDays} 天` : '没有', '你多久没说话了。超过门槛她会记下「你不在」。'),
2054
- rowTip('互动计数', `说 ${s.counts.utterance} · 改口 ${s.counts.revise} · 拒绝 ${s.counts.refusal} · 重聚 ${s.counts.reunion}`, '说=她写过几句自己的判断(含留话);改口=按照规则推翻历史认知并指明改的是哪条;拒绝=她明确登记拒绝过什么;重聚=久别之后她自己写下的感受。'),
2106
+ // issue #32:`counts.utterance` 含对话行(flags 带 uncommitted),把它们减掉之后
2107
+ // 才是「她自己写下、不是答他的话」的句数;那些行单列成「聊天」。
2108
+ rowTip('互动计数', `说 ${Math.max(0, (s.counts.utterance ?? 0) - (s.counts.chat ?? 0))} · 改口 ${s.counts.revise} · 拒绝 ${s.counts.refusal} · 重聚 ${s.counts.reunion}${s.counts.chat ? ` · 聊天 ${s.counts.chat}` : ''}`, '说=她写过几句自己的判断(含留话);改口=按照规则推翻历史认知并指明改的是哪条;拒绝=她明确登记拒绝过什么;重聚=久别之后她自己写下的感受。聊天=对话里她回你的话,单列,不算进「说」。'),
2055
2109
  s.quality ? h('div', { style: { marginTop: '4px', marginBottom: '2px' } },
2056
2110
  h('div', {
2057
2111
  style: { display: 'flex', gap: '8px', alignItems: 'baseline', fontSize: '13px', marginBottom: '5px' },
@@ -2144,14 +2198,14 @@ window.__ModuleLoader__.load({
2144
2198
  hint: '超过这么多天没说话,她会记下「你不在」。' }))),
2145
2199
  // 主动说话(0.8.1 本机实验):只有 .dev 下才出现。频率是上限,不是任务——
2146
2200
  // 开关开着也不代表她每天都会说,说不说由她自己判断(见 lib/proactive.js)。
2147
- // 主动搭话(0.8.1 起,0.9.0 公开):只留三件跟「开口」直接有关的事。
2201
+ // 主动搭话(0.8.1 起,0.9.0 公开):只留跟「开口」直接有关的事。
2148
2202
  // 劲儿与兴趣是另一套东西,各自一张卡——混在一张卡里,用户分不清哪一项在管什么。
2149
2203
  group('主动搭话',
2150
2204
  '她可以自己找你说话,不用你先开口。这里调的是上限:开着也可能好几天不说,那是她的判断,不是坏了。',
2151
2205
  h('div', { className: 'grid2' },
2152
2206
  Toggle({ label: '允许她主动开口', on: proactiveOn,
2153
2207
  onChange: (v) => { setProactiveOn(v); patchProactive({ enabled: v }) },
2154
- hint: '关掉就完全没有这个功能。' }),
2208
+ hint: '关掉:她不会自己开一轮来找你。her_say 这个工具还在,她想说时仍可以用,只是不再由系统替她开这一轮。' }),
2155
2209
  Sel({ label: '一天最多几次', value: proactiveMax,
2156
2210
  onChange: (v) => { setProactiveMax(v); patchProactive({ maxPerDay: Number(v) }) },
2157
2211
  options: [0, 1, 2, 3, 4, 5, 6],
@@ -2160,7 +2214,22 @@ window.__ModuleLoader__.load({
2160
2214
  Num({ label: '你多久没说话,她才可能开口', value: proactiveIdle,
2161
2215
  onChange: (v) => { setProactiveIdle(v); patchProactive({ minIdleHours: Number(v) }) },
2162
2216
  min: 1, max: 24, unit: '小时',
2163
- hint: '越久没说话,她越可能开口(有上限)。刚聊完不会马上又来找你。' }))),
2217
+ hint: '越久没说话,她越可能开口(有上限)。刚聊完不会马上又来找你。' }),
2218
+ Text({ label: '她靠哪些工具把话发出去', value: proactiveTools, wide: true,
2219
+ placeholder: '工具名,逗号分隔;不填就只认正文',
2220
+ // 打字时不逐键保存:停手 700ms 再存,和这一页别的自动保存一样
2221
+ onChange: (v) => {
2222
+ setProactiveTools(v)
2223
+ clearTimeout(proactiveToolsTimer.current)
2224
+ proactiveToolsTimer.current = setTimeout(() => patchProactive({ sendTools: v.split(/[,,\s]+/).filter(Boolean) }), 700)
2225
+ },
2226
+ hint: '她写的字不会被插件自动发出去、要调工具才发得出去时才填,并且要放进下面的插件能力白名单。填了之后只有工具调成功才算她开了口。' }),
2227
+ Toggle({ label: '没开口的那一轮折起来', on: proactiveCompact,
2228
+ onChange: (v) => { setProactiveCompact(v); patchProactive({ foldSilent: v }) },
2229
+ hint: '省上下文。上下文很大、靠缓存省钱时可以关:改历史会让缓存失效。' }),
2230
+ Toggle({ label: '你来过也算打断安静', on: proactiveSeen,
2231
+ onChange: (v) => { setProactiveSeen(v); patchProactive({ idleCountsSeen: v }) },
2232
+ hint: '你只从聊天软件找她、她又不写正文时才开,不然你刚聊完她也觉得安静了很久。代价是你在这台机器上和别的 agent 说话也算。' }))),
2164
2233
  // 主观能动性:不是「定时叫她说话」,而是她心里慢慢攒的一股劲。
2165
2234
  group('主观能动性',
2166
2235
  '她心里会慢慢攒一股想动的劲:越久没自己动过、桌上越有值得看的东西,攒得越快。攒够了才会自己醒一次——不按钟点,也不是每天必须。',
@@ -2170,7 +2239,7 @@ window.__ModuleLoader__.load({
2170
2239
  hint: '关掉:她不会自己开一轮,只在你叫她、或者日程到点时才醒。已经发生的事不受影响。' }))),
2171
2240
  // 兴趣与惦记:她醒来时桌上摆的那几样东西。
2172
2241
  group('兴趣',
2173
- '她醒来的时候,桌上会摆几样她自己的东西:问过还没答案的问题、翻到过的旧句子、你回没回她留的那句话、最近总说到的那件事。这些只给她看,不会变成待办。',
2242
+ '她醒来的时候,桌上会摆几样她自己的东西:她反复问起过的事、翻到过的旧句子、你回没回她留的那句话、最近总说到的那件事。这些只给她看,不会变成待办。关掉之后连旧句子也不再摆,她只会按时被叫醒。',
2174
2243
  h('div', { className: 'grid2' },
2175
2244
  Toggle({ label: '醒来时把这些摆出来', on: interestOn,
2176
2245
  onChange: (v) => { setInterestOn(v); patchGroup('interest', { enabled: v }) },
@@ -2240,6 +2309,8 @@ window.__ModuleLoader__.load({
2240
2309
  else setAskMsg(String(r?.why || '没答上。'))
2241
2310
  } catch (e) {
2242
2311
  setAskMsg(`没答上:${e.message}`)
2312
+ // 多半是别的地方先答了:把列表刷新一下,别还留着这张卡
2313
+ jget('/ask').then(setAsks).catch(() => {})
2243
2314
  }
2244
2315
  }
2245
2316
 
@@ -2517,15 +2588,21 @@ window.__ModuleLoader__.load({
2517
2588
  // 旧版本(≤0.5.9)会把登录账号写进这两格,只有点「保存」才会重写;这里点出来,别让人以为是刷新丢的。
2518
2589
  owMid && ow?.account?.mid && String(owMid) === String(ow.account.mid)
2519
2590
  ? h('p', { className: 'warn' },
2520
- '这两格现在跟下面登录的账号是同一个号,像是旧版本写进去的。改成你要 @ 的 uid 和昵称,再点「保存」。')
2591
+ '这两格现在跟下面登录的账号是同一个号,像是旧版本写进去的。改成你要 @ 的 uid 和昵称,再点下面「保存这两格」。')
2521
2592
  : null,
2522
2593
  h('div', { className: 'grid2' },
2523
2594
  Text({ label: '要 @ 的 B 站 uid', value: owMid, onChange: setOwMid, placeholder: '数字,比如 12345678', wide: true,
2524
2595
  hint: '就是 B 站个人主页地址里那串数字。' }),
2525
2596
  Text({ label: '要 @ 的 B 站昵称', value: owName, onChange: setOwName, placeholder: '评论里 @ 的就是这个名字', wide: true,
2526
- hint: '没填昵称就 @ 不出来——空的 @ 只是一串没人认领的字符。' }),
2597
+ hint: '要跟 B 站上显示的一字不差(带空格也照填)。没填就 @ 不出来——空的 @ 只是一串没人认领的字符。' }),
2527
2598
  Toggle({ label: '发评论时在最前面 @ 你', on: atOwner, onChange: setAtOwner,
2528
- 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)),
2529
2606
  group('B 站登录',
2530
2607
  '她刷视频、发评论用的账号,建议放一个小号。没存也能看视频,只是不能说话。',
2531
2608
  // 两个入口:扫码是给人用的,粘贴是退路(扫码不顺利、或者想换个号)。
@@ -2536,7 +2613,7 @@ window.__ModuleLoader__.load({
2536
2613
 
2537
2614
  h('div', { className: 'dim', style: { marginBottom: '8px' } },
2538
2615
  ow?.hasCookie
2539
- ? `已存${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(不能发评论)'}。换一份就重新登。`
2540
2617
  : '还没存。'),
2541
2618
 
2542
2619
  owTab === 'qr'
@@ -2551,7 +2628,7 @@ window.__ModuleLoader__.load({
2551
2628
  qrNote ? h('span', { className: 'qrerr', style: { margin: 0 } }, qrNote) : null))
2552
2629
  : h('div', null,
2553
2630
  h('p', { className: 'hint', style: { margin: '0 0 8px' } },
2554
- 'cookie 存好之后为隐私起见不再回显,输入框永远是空的。'),
2631
+ 'cookie 存好之后为隐私起见不再回显,输入框永远是空的——空着不代表没存上,上面那行「已存…」才是凭据。'),
2555
2632
  h('textarea', {
2556
2633
  rows: 3,
2557
2634
  value: owCookie,
@@ -2562,13 +2639,18 @@ window.__ModuleLoader__.load({
2562
2639
  String(ow?.hint ?? '').split('\\n').map((line, i) => h('div', { key: i }, line))),
2563
2640
  h('div', { className: 'row', style: { marginTop: '8px' } },
2564
2641
  h('button', { onClick: saveOwnerCard, disabled: busy === 'owner' },
2565
- busy === 'owner' ? '保存中…' : '保存'),
2566
- h('button', { onClick: testOwner, disabled: busy === 'owtest' },
2567
- busy === 'owtest' ? '测试中…' : '测试 cookie 能不能说话'),
2568
- h('span', { className: 'hint' }, '只验下面那份 cookie,不保存、不动上面两格;两格要改就点「保存」。'))),
2642
+ busy === 'owner' ? '保存中…' : '保存 cookie'),
2643
+ h('span', { className: 'hint' }, '「保存 cookie」会顺带存一次上面填了的格子;只想改那两格就点上面那组里的「保存这两格」。'))),
2569
2644
 
2570
2645
  h('div', { className: 'row', style: { marginTop: '8px' } },
2571
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,
2572
2654
  ow?.hasCookie
2573
2655
  ? h('button', {
2574
2656
  disabled: busy === 'owclear',
@@ -2577,6 +2659,9 @@ window.__ModuleLoader__.load({
2577
2659
  clearLogin()
2578
2660
  },
2579
2661
  }, busy === 'owclear' ? '清除中…' : '清除登录')
2662
+ : null,
2663
+ ow?.hasCookie
2664
+ ? h('span', { className: 'hint' }, '测试只验这份 cookie,不保存、也不动上面那两格。')
2580
2665
  : null)),
2581
2666
 
2582
2667
  // ---- 以前看了什么(视频观看历史,分页) ----
@@ -2658,7 +2743,7 @@ window.__ModuleLoader__.load({
2658
2743
  '一个侧栏会话聊到几百轮,上下文必然撑爆——不是变慢,是每轮都为几十万字付费,最后直接失败。',
2659
2744
  h('div', { className: 'grid2' },
2660
2745
  Num({ label: '聊满多少轮换新会话', value: rotateAfter, onChange: setRotateAfter, min: 0, max: 500, unit: '轮',
2661
- hint: '请在不断的循环中,找到能引发本质变化的唯一解。0 = 永不换。上下文占满后会将当前已形成的核心认知和初始信息,作为提示词带到新会话。' }),
2746
+ hint: '请在不断的循环中,找到能引发本质变化的唯一解。0 = 永不换——那条会话会一直聊到宿主上下文撑不住为止,之后这一轮会直接失败(不会自动换)。换的时候会把当前已形成的核心认知和初始信息,作为提示词带到新会话。' }),
2662
2747
  Num({ label: '单次上下文限制(身份文档多少字)', value: docBudget, onChange: setDocBudget, min: 600, max: 4000, step: 100, unit: '字',
2663
2748
  hint: '超了按照规则遗忘部分认知/经历;「初始人格」和四条禁令不会遗忘。根据经历不同,部分认知可能会变成永久记忆不再遗忘。' }))),
2664
2749
 
@@ -2705,7 +2790,9 @@ window.__ModuleLoader__.load({
2705
2790
  mem.sections?.voiceLast
2706
2791
  ? h('div', { className: 'dim' }, ` 最近读到:「${mem.sections.voiceLast.text}」${mem.sections.voiceLast.echoed ? '(她认下了)' : ''}`)
2707
2792
  : null,
2708
- h('div', null, `· 记下的事:${mem.events ?? 0} 条 · 日记 ${mem.diaries ?? 0} 篇`),
2793
+ // issue #32:口径同面板标题那行——含她读到的别人说法与开局起点,不只她写的。
2794
+ h('div', { title: '她自己写的判断、留话、日记,加上她读到并记下的别人说法和开局那份起点;不含系统后台记账。' },
2795
+ `· 记下的事:${mem.events ?? 0} 条 · 日记 ${mem.diaries ?? 0} 篇`),
2709
2796
  h('div', null, `· 这条会话:${mem.session?.rotateAfter > 0
2710
2797
  ? `${mem.session?.turns ?? 0} / ${mem.session.rotateAfter} 轮,满了自动换新会话`
2711
2798
  : '不自动换'}`),
@@ -3311,6 +3398,7 @@ window.__ModuleLoader__.load({
3311
3398
  async function doDecideKeepsake(id, yes) {
3312
3399
  setOutingBusy(true)
3313
3400
  setOutingMsg(yes ? '正在带回来……' : '正在回绝……')
3401
+ const refreshOuting = () => jget('/outing').then(setOuting).catch(() => {})
3314
3402
  try {
3315
3403
  const r = await jpost('/outing', { action: 'keepsake', id, yes })
3316
3404
  setOuting(await jget('/outing').catch(() => outing))
@@ -3321,6 +3409,8 @@ window.__ModuleLoader__.load({
3321
3409
  )
3322
3410
  } catch (e) {
3323
3411
  setOutingMsg(`没处理成:${e.message}`)
3412
+ // 多半是别的地方先答了:刷新一下,别还留着这一行
3413
+ refreshOuting()
3324
3414
  } finally {
3325
3415
  setOutingBusy(false)
3326
3416
  }
@@ -4082,7 +4172,14 @@ window.__ModuleLoader__.load({
4082
4172
 
4083
4173
  return h('div', { className: 'dfish', ref: (el) => { topEl = el } },
4084
4174
  h('h2', null, '大肥鱼'),
4085
- h('p', { className: 'sub' },
4175
+ h('p', {
4176
+ className: 'sub',
4177
+ // issue #32:这个数字(counts.total = countEvents().real)从来不只是「她写的」——
4178
+ // 她读到的别人说法、开局那份起点、日记、答你的话都在里面。以前文案写「她记下
4179
+ // N 条」,跟面板上「记下的事」一起被当成了「她写了 N 条」,于是永远对不上。
4180
+ // 口径不改(改 real 的定义会动事件流语义与一堆派生数字),把话说清楚。
4181
+ title: '她自己写的判断、留话、日记,加上她读到并记下的别人说法和开局那份起点;不含系统后台记账(另计)。',
4182
+ },
4086
4183
  s ? `第 ${s.day} / ${s.growthDays} 天 · ${s.stageName} · 她记下 ${s.counts.total} 条${s.counts.meta ? `,另有 ${s.counts.meta} 条系统记录` : ''}` : '读取中…'),
4087
4184
  err ? h('pre', { className: 'err' }, err) : null,
4088
4185
  note ? h('pre', { className: 'ok' }, note) : null,