dsh-wisp 1.34.1 → 1.35.0

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/README.md CHANGED
@@ -448,6 +448,41 @@ node tools/audit-log.mjs --confirm <manifest> # 生成后:并入代理登
448
448
 
449
449
  ## 变更
450
450
 
451
+ ### 1.35.0
452
+
453
+ **她开始替你"值班"。** 在这之前,你走开时她只是**安静下来**;回来只说一句"你走了好久"。
454
+ 可那段时间里跑完的轮次、出的错,她本来就看得到 —— 以前只是没人把它们留下来。
455
+
456
+ #### ① 值班
457
+
458
+ - **走开期间**:她放慢轮询(**1.2s → 5s**)但**不停** —— 值班的前提是还看得见。
459
+ 絮叨和踱步照旧停掉(那些只给看得见的人看)。
460
+ - **只记账,不表演**:藏起来时她不换表情、不说话。否则"只说一次"的状态会被白白用掉
461
+ (里程碑在没人看的时候被说掉,你回来就不会再提了)。
462
+ - **回来报账**:*"你走的这段时间,跑完 3 轮了。出了 2 次错。"* ——
463
+ 没有账的时候才是原来那句问候。
464
+ - **单轮跑得异常久**(超过 **10 分钟**)她会中途说一声:*"这轮跑了 10 分钟了,还在跑。"*
465
+ 同一轮只说一次,而且不是催促 —— 是"我还在看着"。
466
+
467
+ #### ② 今日小结
468
+
469
+ 菜单「行为」里多了一项 **今天做了什么**(也可以 `__wisp.saySummary()`):
470
+ *"今天:7 轮、2 次报错、专注 50 分钟、起来活动 3 次。记着呢。"*
471
+
472
+ 她记的是:**轮次 / 报错次数 / 最长一轮 / 专注总时长 / 提醒你起来的次数**。
473
+ 存在记忆里、**按天重置** —— 刷新页面不会丢,第二天重新算。
474
+
475
+ #### 可观测性
476
+
477
+ `doctor()` 里多了 `today`(今天的账)、`watch`(走开期间的值班记录)、
478
+ `runInFlightMs` 与 `longRunSaid`(当前这轮跑了多久、有没有已经提过)。
479
+ 最后两个是**被测试逼出来的**:一条断言失败时,诊断直接说出 `runInFlight=599800` ——
480
+ 差 200ms 没到阈值,问题在测试的时间轴而不是功能。
481
+
482
+ 预检 529 → **539 项**;冒烟 57 项。
483
+
484
+ ### 1.34.1
485
+
451
486
  ### 1.34.1
452
487
 
453
488
  **菜单改成紧凑刻度。** 上一版样式好看但**占地太大** —— 对陪伴插件的浮层来说不合适,
package/lib/client.js CHANGED
@@ -51,7 +51,7 @@ window.__ModuleLoader__.load({
51
51
  var exports = module.exports
52
52
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
53
53
 
54
- const VERSION = '1.34.1'
54
+ const VERSION = '1.35.0'
55
55
 
56
56
  /* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
57
57
  忘了更新就会红,不会静默过期。 */
@@ -78,6 +78,7 @@ const WHATS_NEW = {
78
78
  '1.33.2': '修她在屏幕四角时子菜单跟着偏的问题。',
79
79
  '1.34.0': '菜单样式重做:摘要做成胶囊、每行都有图标位、键盘焦点看得见。',
80
80
  '1.34.1': '菜单改成紧凑刻度,占屏幕小了三成。',
81
+ '1.35.0': '她开始替你值班:你走开时看着,回来报账;还能说今日小结。',
81
82
  }
82
83
 
83
84
  /* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
@@ -333,6 +334,22 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
333
334
  '算力不够了,得补点碳水。', '电量告急,切待机。', '你要不要也来一口?',
334
335
  '这碗不算加餐,算基础设施。', '吃饱了才有力气摸鱼。', '饭碗见底了,该续命了。', '我数着米粒呢。',
335
336
  ],
337
+ /* 值班:你走开的时候她替你看着,回来汇报。分片拼装(跑了多少轮 / 有没有报错),
338
+ 因为三种组合(只有轮次、只有报错、两者都有)都该读起来像人话。 */
339
+ awayOpen: ['你走的这段时间,', '刚才那会儿,', '我没走开 —— '],
340
+ awayRuns: ['跑完了 {n} 轮。', '跑完 {n} 轮了。', '有 {n} 轮跑完了。'],
341
+ awayErrors: ['有一次报错,我看过了。', '出了 {n} 次错。', '有 {n} 次报错,你回来看一眼。'],
342
+ /* 单轮跑得异常久:不是催促,是"我还在看着" */
343
+ longRun: [
344
+ '这轮跑了 {n} 分钟了,还在跑。', '{n} 分钟了,我一直看着。', '还在跑,已经 {n} 分钟。',
345
+ '{n} 分钟 —— 要不要我替你盯着,你先去忙?',
346
+ ],
347
+ /* 今日小结:把她记着的东西一次说完 */
348
+ summary: [
349
+ '今天跑了 {runs} 轮,报错 {errors} 次,专注 {focus} 分钟 —— 我提醒你起来 {care} 次。',
350
+ '今天:{runs} 轮、{errors} 次报错、专注 {focus} 分钟、起来活动 {care} 次。记着呢。',
351
+ '这一天跑完 {runs} 轮,出错 {errors} 次,专注 {focus} 分钟。我提醒过你 {care} 次别久坐。',
352
+ ],
336
353
  /* 专注计时器。她是陪着你干活的,所以"替你画出一段时间的边界"比再多说几句更实在。 */
337
354
  focusStart: [
338
355
  '{n} 分钟。开始吧。', '好,{n} 分钟,我盯着你。', '计时开始,{n} 分钟。', '{n} 分钟不看别的。',
@@ -1515,6 +1532,47 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1515
1532
  return null
1516
1533
  }
1517
1534
 
1535
+ /* ---- 今天记了些什么 -----------------------------------------------------
1536
+ 轮次、报错、专注时长、提醒次数 —— 这些数字本来就在手上,以前只是没留下来。
1537
+ 存在记忆里(按天重置),刷新页面也不会丢,这样"今日小结"才说得准。 */
1538
+ const watchToday = () => {
1539
+ const today = dayKey()
1540
+ if (!memory.stats || memory.statsDay !== today) {
1541
+ memory.stats = { runs: 0, errors: 0, longestMs: 0, focusMs: 0, care: 0 }
1542
+ memory.statsDay = today
1543
+ saveMemory()
1544
+ }
1545
+ return memory.stats
1546
+ }
1547
+ const bumpStats = (patchStats) => {
1548
+ const stats = watchToday()
1549
+ for (const key of Object.keys(patchStats)) {
1550
+ if (typeof stats[key] !== 'number') stats[key] = 0
1551
+ stats[key] += patchStats[key]
1552
+ }
1553
+ saveMemory()
1554
+ return stats
1555
+ }
1556
+ const saySummary = () => {
1557
+ const stats = watchToday()
1558
+ const text = pick(LINES.summary)
1559
+ .split('{runs}').join(String(stats.runs))
1560
+ .split('{errors}').join(String(stats.errors))
1561
+ .split('{focus}').join(String(Math.round(stats.focusMs / 60000)))
1562
+ .split('{care}').join(String(stats.care))
1563
+ wake(true)
1564
+ flashHappy(text)
1565
+ }
1566
+
1567
+ /* ---- 值班 ---------------------------------------------------------------
1568
+ 你在的时候她陪你;你走开的时候她替你看着:跑完了几轮、有没有报错。
1569
+ 藏起来时**放慢**轮询而不是停掉 —— 值班的前提是还看得见(5 秒的延迟对这件事足够)。
1570
+ 回来时把那段时间的账报给你。 */
1571
+ const HIDDEN_POLL_MS = 5000
1572
+ const LONG_RUN_MS = 600000
1573
+ let longRunSaidAt = 0
1574
+ let awayWatch = null
1575
+
1518
1576
  /* ---- react to what the app is doing ---- */
1519
1577
  let lastBusy = false
1520
1578
  let lastPending = null
@@ -1618,10 +1676,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1618
1676
  /* 一轮刚跑完的那一刻看一次错误。同一批错误只说一次;错误清掉之后再出现新的
1619
1677
  才会再说 —— 否则每轮结束都会重复念同一句。 */
1620
1678
  const checkErrors = () => {
1621
- if (!cfg.reactions) return
1679
+ if (!cfg.reactions) return 0
1622
1680
  const count = countErrors()
1623
- if (count > 0 && count !== lastErrorCount) flashWorried(pick(LINES.error))
1681
+ /* 只有"新出现"的才算一次:同一批错误不该被数很多遍(值班汇报要的是新账) */
1682
+ const fresh = count > lastErrorCount ? count - lastErrorCount : 0
1683
+ if (count > 0 && count !== lastErrorCount && !paused) flashWorried(pick(LINES.error))
1624
1684
  lastErrorCount = count
1685
+ return fresh
1625
1686
  }
1626
1687
 
1627
1688
  const poll = () => {
@@ -1632,16 +1693,39 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1632
1693
  if (!busy && lastBusy) {
1633
1694
  /* 里程碑优先于普通庆祝:同一次跑完只该说一句,而"第 25 轮"比"跑完了"更值得说 */
1634
1695
  const hit = cfg.reactions && cfg.milestones ? recordRun() : null
1635
- if (hit !== null) {
1636
- flashProud(pick(LINES.milestone).split('{n}').join(String(hit)))
1637
- } else if (cfg.celebrate && nowMs() - busySince >= cfg.celebrateAfterMs) {
1638
- flashHappy(pick(LINES.done))
1696
+ /* 藏起来时**只记账不表演**:没人看得见她,而且"只说一次"的状态会被白白用掉
1697
+ (里程碑在没人看的时候被说掉,你回来就不会再提了)。回来有专门的汇报。 */
1698
+ if (!paused) {
1699
+ if (hit !== null) {
1700
+ flashProud(pick(LINES.milestone).split('{n}').join(String(hit)))
1701
+ } else if (cfg.celebrate && nowMs() - busySince >= cfg.celebrateAfterMs) {
1702
+ flashHappy(pick(LINES.done))
1703
+ }
1639
1704
  }
1640
1705
  // 一轮结束 = 错误唯一会出现的时机,所以错误检查挂在这个边上而不是轮询里
1641
- checkErrors()
1706
+ const fresh = checkErrors()
1707
+ /* 记账:今天跑了多少轮、最长一轮多久、有没有新报错;走开期间的另记一份 */
1708
+ const ranMs = busySince > 0 ? nowMs() - busySince : 0
1709
+ bumpStats({ runs: 1, errors: fresh })
1710
+ const stats = watchToday()
1711
+ if (ranMs > stats.longestMs) { stats.longestMs = ranMs; saveMemory() }
1712
+ if (awayWatch !== null) {
1713
+ awayWatch.runs++
1714
+ awayWatch.errors += fresh
1715
+ if (ranMs > awayWatch.longestMs) awayWatch.longestMs = ranMs
1716
+ }
1642
1717
  }
1643
1718
  lastBusy = busy
1644
1719
 
1720
+ /* 单轮跑得异常久:她中途说一声(同一轮只说一次)。不是催促,是"我还在看着"。 */
1721
+ if (busy && busySince > 0) {
1722
+ const ran = nowMs() - busySince
1723
+ if (!paused && ran >= LONG_RUN_MS && longRunSaidAt < busySince) {
1724
+ longRunSaidAt = nowMs()
1725
+ say(pick(LINES.longRun).split('{n}').join(String(Math.round(ran / 60000))))
1726
+ }
1727
+ }
1728
+
1645
1729
  /* ---- 久坐追踪 ---------------------------------------------------------
1646
1730
  "连着坐了多久"没有直接信号,用"最近有没有活动"近似:跑着任务或输入框里有字
1647
1731
  就算还在动。中间短暂停一下(不到 CARE_ACTIVE_GAP_MS)不算离开 —— 人是会停下来
@@ -1666,6 +1750,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1666
1750
  /* 提醒你的时候她自己得先醒着 —— 打盹着喊人起来活动,读起来像 bug。
1667
1751
  pending 面板那条路也是这么处理的(先 wake 再说话)。 */
1668
1752
  if (mood === 'sleep') wake(true)
1753
+ bumpStats({ care: 1 })
1669
1754
  flashCare(pick(LINES.care))
1670
1755
  }
1671
1756
 
@@ -1760,6 +1845,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1760
1845
  focusHandle = null
1761
1846
  focusMinutes = 0
1762
1847
  focusDone++
1848
+ bumpStats({ focusMs: minutes * 60000 })
1763
1849
  wake(true)
1764
1850
  flashProud(pick(LINES.focusDone).split('{n}').join(String(minutes)))
1765
1851
  }
@@ -2646,6 +2732,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2646
2732
  children: [
2647
2733
  { label: '说句话', glyph: '✦', run: () => { wake(true); say(pick(LINES.click)) } },
2648
2734
  { label: '睡一会儿', glyph: '☾', run: () => setMood('sleep') },
2735
+ { label: '今天做了什么', glyph: '✎', run: () => saySummary() },
2649
2736
  /* 开关是**真正的开关行**:带 aria-checked,点完菜单不关(就在原地更新) */
2650
2737
  ...TOGGLES.map((t) => ({ kind: 'switch', key: t.key, label: t.label })),
2651
2738
  ],
@@ -2931,11 +3018,15 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2931
3018
  try { state = document.visibilityState === 'hidden' ? 'hidden' : 'visible' } catch (e) { state = 'visible' }
2932
3019
  if (state === 'hidden') {
2933
3020
  awayAt = nowMs()
2934
- /* 没人在看,停掉三个"给看得见的人看"的定时器 */
3021
+ /* 絮叨和踱步停掉(它们本来就只给看得见的人看),但**轮询放慢而不是停掉** ——
3022
+ 值班的前提是还看得见:你走开时跑完的那几轮、出的错,都得记下来。 */
2935
3023
  paused = true
3024
+ awayWatch = { runs: 0, errors: 0, longestMs: 0, at: awayAt }
2936
3025
  syncPoll()
2937
3026
  syncChatter()
2938
3027
  syncWander()
3028
+ if (pollHandle) { clock.cancel(pollHandle); pollHandle = null }
3029
+ pollHandle = clock.every(HIDDEN_POLL_MS, poll)
2939
3030
  return
2940
3031
  }
2941
3032
  /* 回来了:先恢复再算问候 —— 恢复是必须的,问候才是锦上添花 */
@@ -2947,9 +3038,19 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2947
3038
  }
2948
3039
  if (awayAt === 0) return
2949
3040
  const away = nowMs() - awayAt
3041
+ const watch = awayWatch
3042
+ awayWatch = null
2950
3043
  awayAt = 0
2951
3044
  if (!(cfg.backAfterMs > 0) || away < cfg.backAfterMs) return
2952
3045
  wake(true)
3046
+ /* 有账就报账(这才是"值班"的意义);没账才是普通的那句问候。 */
3047
+ if (watch !== null && (watch.runs > 0 || watch.errors > 0)) {
3048
+ const bits = []
3049
+ if (watch.runs > 0) bits.push(pick(LINES.awayRuns).split('{n}').join(String(watch.runs)))
3050
+ if (watch.errors > 0) bits.push(pick(LINES.awayErrors).split('{n}').join(String(watch.errors)))
3051
+ say(pick(LINES.awayOpen) + bits.join(''))
3052
+ return
3053
+ }
2953
3054
  say(pick(away >= BACK_LONG_MS ? LINES.backLong : LINES.backSoon))
2954
3055
  }
2955
3056
  if (typeof document.addEventListener === 'function') {
@@ -3181,6 +3282,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3181
3282
  /* 专注计时器:脚本与测试都能驱动 */
3182
3283
  startFocus,
3183
3284
  stopFocus,
3285
+ saySummary,
3286
+ get today() { return { ...watchToday() } },
3184
3287
  get focus() { return focusState() },
3185
3288
  openAbout: () => aboutDialog(),
3186
3289
  openActions: () => actionsDialog(),
@@ -3311,11 +3414,19 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3311
3414
  },
3312
3415
  streak: streakOf(memory.days),
3313
3416
  awaySince: Math.round(awayAt),
3417
+ /* 今天记了什么 —— 不用猜"她为什么说今天跑了 7 轮" */
3418
+ today: { ...watchToday() },
3419
+ /* 走开期间的值班记录(不在值班时为 null) */
3420
+ watch: awayWatch === null ? null : { ...awayWatch, forMs: Math.round(nowMs() - awayWatch.at) },
3421
+ /* 单轮跑了多久、有没有已经提过 —— 断言失败时能自己说出原因,不用猜 */
3422
+ runInFlightMs: busySince > 0 && lastBusy ? Math.round(nowMs() - busySince) : 0,
3423
+ longRunSaid: longRunSaidAt > 0,
3314
3424
  /* 专注:不用猜"她为什么一直站在干活中" */
3315
3425
  focus: focusState(),
3316
3426
  /* 定时器是否挂着;页面藏起来时三个都该是 false */
3317
3427
  timers: {
3318
- poll: pollHandle !== null, chatter: chatterHandle !== null,
3428
+ poll: pollHandle !== null,
3429
+ pollMs: paused ? HIDDEN_POLL_MS : cfg.pollMs, chatter: chatterHandle !== null,
3319
3430
  wander: wanderHandle !== null, paused,
3320
3431
  },
3321
3432
  /* 今日轮次:不用猜"她为什么突然得意",数字就在这里 */
@@ -51,7 +51,7 @@ window.__ModuleLoader__.load({
51
51
  var exports = module.exports
52
52
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
53
53
 
54
- const VERSION = '1.34.1'
54
+ const VERSION = '1.35.0'
55
55
 
56
56
  /* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
57
57
  忘了更新就会红,不会静默过期。 */
@@ -78,6 +78,7 @@ const WHATS_NEW = {
78
78
  '1.33.2': '修她在屏幕四角时子菜单跟着偏的问题。',
79
79
  '1.34.0': '菜单样式重做:摘要做成胶囊、每行都有图标位、键盘焦点看得见。',
80
80
  '1.34.1': '菜单改成紧凑刻度,占屏幕小了三成。',
81
+ '1.35.0': '她开始替你值班:你走开时看着,回来报账;还能说今日小结。',
81
82
  }
82
83
 
83
84
  /* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
@@ -282,6 +283,22 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
282
283
  '算力不够了,得补点碳水。', '电量告急,切待机。', '你要不要也来一口?',
283
284
  '这碗不算加餐,算基础设施。', '吃饱了才有力气摸鱼。', '饭碗见底了,该续命了。', '我数着米粒呢。',
284
285
  ],
286
+ /* 值班:你走开的时候她替你看着,回来汇报。分片拼装(跑了多少轮 / 有没有报错),
287
+ 因为三种组合(只有轮次、只有报错、两者都有)都该读起来像人话。 */
288
+ awayOpen: ['你走的这段时间,', '刚才那会儿,', '我没走开 —— '],
289
+ awayRuns: ['跑完了 {n} 轮。', '跑完 {n} 轮了。', '有 {n} 轮跑完了。'],
290
+ awayErrors: ['有一次报错,我看过了。', '出了 {n} 次错。', '有 {n} 次报错,你回来看一眼。'],
291
+ /* 单轮跑得异常久:不是催促,是"我还在看着" */
292
+ longRun: [
293
+ '这轮跑了 {n} 分钟了,还在跑。', '{n} 分钟了,我一直看着。', '还在跑,已经 {n} 分钟。',
294
+ '{n} 分钟 —— 要不要我替你盯着,你先去忙?',
295
+ ],
296
+ /* 今日小结:把她记着的东西一次说完 */
297
+ summary: [
298
+ '今天跑了 {runs} 轮,报错 {errors} 次,专注 {focus} 分钟 —— 我提醒你起来 {care} 次。',
299
+ '今天:{runs} 轮、{errors} 次报错、专注 {focus} 分钟、起来活动 {care} 次。记着呢。',
300
+ '这一天跑完 {runs} 轮,出错 {errors} 次,专注 {focus} 分钟。我提醒过你 {care} 次别久坐。',
301
+ ],
285
302
  /* 专注计时器。她是陪着你干活的,所以"替你画出一段时间的边界"比再多说几句更实在。 */
286
303
  focusStart: [
287
304
  '{n} 分钟。开始吧。', '好,{n} 分钟,我盯着你。', '计时开始,{n} 分钟。', '{n} 分钟不看别的。',
@@ -1464,6 +1481,47 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1464
1481
  return null
1465
1482
  }
1466
1483
 
1484
+ /* ---- 今天记了些什么 -----------------------------------------------------
1485
+ 轮次、报错、专注时长、提醒次数 —— 这些数字本来就在手上,以前只是没留下来。
1486
+ 存在记忆里(按天重置),刷新页面也不会丢,这样"今日小结"才说得准。 */
1487
+ const watchToday = () => {
1488
+ const today = dayKey()
1489
+ if (!memory.stats || memory.statsDay !== today) {
1490
+ memory.stats = { runs: 0, errors: 0, longestMs: 0, focusMs: 0, care: 0 }
1491
+ memory.statsDay = today
1492
+ saveMemory()
1493
+ }
1494
+ return memory.stats
1495
+ }
1496
+ const bumpStats = (patchStats) => {
1497
+ const stats = watchToday()
1498
+ for (const key of Object.keys(patchStats)) {
1499
+ if (typeof stats[key] !== 'number') stats[key] = 0
1500
+ stats[key] += patchStats[key]
1501
+ }
1502
+ saveMemory()
1503
+ return stats
1504
+ }
1505
+ const saySummary = () => {
1506
+ const stats = watchToday()
1507
+ const text = pick(LINES.summary)
1508
+ .split('{runs}').join(String(stats.runs))
1509
+ .split('{errors}').join(String(stats.errors))
1510
+ .split('{focus}').join(String(Math.round(stats.focusMs / 60000)))
1511
+ .split('{care}').join(String(stats.care))
1512
+ wake(true)
1513
+ flashHappy(text)
1514
+ }
1515
+
1516
+ /* ---- 值班 ---------------------------------------------------------------
1517
+ 你在的时候她陪你;你走开的时候她替你看着:跑完了几轮、有没有报错。
1518
+ 藏起来时**放慢**轮询而不是停掉 —— 值班的前提是还看得见(5 秒的延迟对这件事足够)。
1519
+ 回来时把那段时间的账报给你。 */
1520
+ const HIDDEN_POLL_MS = 5000
1521
+ const LONG_RUN_MS = 600000
1522
+ let longRunSaidAt = 0
1523
+ let awayWatch = null
1524
+
1467
1525
  /* ---- react to what the app is doing ---- */
1468
1526
  let lastBusy = false
1469
1527
  let lastPending = null
@@ -1567,10 +1625,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1567
1625
  /* 一轮刚跑完的那一刻看一次错误。同一批错误只说一次;错误清掉之后再出现新的
1568
1626
  才会再说 —— 否则每轮结束都会重复念同一句。 */
1569
1627
  const checkErrors = () => {
1570
- if (!cfg.reactions) return
1628
+ if (!cfg.reactions) return 0
1571
1629
  const count = countErrors()
1572
- if (count > 0 && count !== lastErrorCount) flashWorried(pick(LINES.error))
1630
+ /* 只有"新出现"的才算一次:同一批错误不该被数很多遍(值班汇报要的是新账) */
1631
+ const fresh = count > lastErrorCount ? count - lastErrorCount : 0
1632
+ if (count > 0 && count !== lastErrorCount && !paused) flashWorried(pick(LINES.error))
1573
1633
  lastErrorCount = count
1634
+ return fresh
1574
1635
  }
1575
1636
 
1576
1637
  const poll = () => {
@@ -1581,16 +1642,39 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1581
1642
  if (!busy && lastBusy) {
1582
1643
  /* 里程碑优先于普通庆祝:同一次跑完只该说一句,而"第 25 轮"比"跑完了"更值得说 */
1583
1644
  const hit = cfg.reactions && cfg.milestones ? recordRun() : null
1584
- if (hit !== null) {
1585
- flashProud(pick(LINES.milestone).split('{n}').join(String(hit)))
1586
- } else if (cfg.celebrate && nowMs() - busySince >= cfg.celebrateAfterMs) {
1587
- flashHappy(pick(LINES.done))
1645
+ /* 藏起来时**只记账不表演**:没人看得见她,而且"只说一次"的状态会被白白用掉
1646
+ (里程碑在没人看的时候被说掉,你回来就不会再提了)。回来有专门的汇报。 */
1647
+ if (!paused) {
1648
+ if (hit !== null) {
1649
+ flashProud(pick(LINES.milestone).split('{n}').join(String(hit)))
1650
+ } else if (cfg.celebrate && nowMs() - busySince >= cfg.celebrateAfterMs) {
1651
+ flashHappy(pick(LINES.done))
1652
+ }
1588
1653
  }
1589
1654
  // 一轮结束 = 错误唯一会出现的时机,所以错误检查挂在这个边上而不是轮询里
1590
- checkErrors()
1655
+ const fresh = checkErrors()
1656
+ /* 记账:今天跑了多少轮、最长一轮多久、有没有新报错;走开期间的另记一份 */
1657
+ const ranMs = busySince > 0 ? nowMs() - busySince : 0
1658
+ bumpStats({ runs: 1, errors: fresh })
1659
+ const stats = watchToday()
1660
+ if (ranMs > stats.longestMs) { stats.longestMs = ranMs; saveMemory() }
1661
+ if (awayWatch !== null) {
1662
+ awayWatch.runs++
1663
+ awayWatch.errors += fresh
1664
+ if (ranMs > awayWatch.longestMs) awayWatch.longestMs = ranMs
1665
+ }
1591
1666
  }
1592
1667
  lastBusy = busy
1593
1668
 
1669
+ /* 单轮跑得异常久:她中途说一声(同一轮只说一次)。不是催促,是"我还在看着"。 */
1670
+ if (busy && busySince > 0) {
1671
+ const ran = nowMs() - busySince
1672
+ if (!paused && ran >= LONG_RUN_MS && longRunSaidAt < busySince) {
1673
+ longRunSaidAt = nowMs()
1674
+ say(pick(LINES.longRun).split('{n}').join(String(Math.round(ran / 60000))))
1675
+ }
1676
+ }
1677
+
1594
1678
  /* ---- 久坐追踪 ---------------------------------------------------------
1595
1679
  "连着坐了多久"没有直接信号,用"最近有没有活动"近似:跑着任务或输入框里有字
1596
1680
  就算还在动。中间短暂停一下(不到 CARE_ACTIVE_GAP_MS)不算离开 —— 人是会停下来
@@ -1615,6 +1699,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1615
1699
  /* 提醒你的时候她自己得先醒着 —— 打盹着喊人起来活动,读起来像 bug。
1616
1700
  pending 面板那条路也是这么处理的(先 wake 再说话)。 */
1617
1701
  if (mood === 'sleep') wake(true)
1702
+ bumpStats({ care: 1 })
1618
1703
  flashCare(pick(LINES.care))
1619
1704
  }
1620
1705
 
@@ -1709,6 +1794,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1709
1794
  focusHandle = null
1710
1795
  focusMinutes = 0
1711
1796
  focusDone++
1797
+ bumpStats({ focusMs: minutes * 60000 })
1712
1798
  wake(true)
1713
1799
  flashProud(pick(LINES.focusDone).split('{n}').join(String(minutes)))
1714
1800
  }
@@ -2595,6 +2681,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2595
2681
  children: [
2596
2682
  { label: '说句话', glyph: '✦', run: () => { wake(true); say(pick(LINES.click)) } },
2597
2683
  { label: '睡一会儿', glyph: '☾', run: () => setMood('sleep') },
2684
+ { label: '今天做了什么', glyph: '✎', run: () => saySummary() },
2598
2685
  /* 开关是**真正的开关行**:带 aria-checked,点完菜单不关(就在原地更新) */
2599
2686
  ...TOGGLES.map((t) => ({ kind: 'switch', key: t.key, label: t.label })),
2600
2687
  ],
@@ -2880,11 +2967,15 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2880
2967
  try { state = document.visibilityState === 'hidden' ? 'hidden' : 'visible' } catch (e) { state = 'visible' }
2881
2968
  if (state === 'hidden') {
2882
2969
  awayAt = nowMs()
2883
- /* 没人在看,停掉三个"给看得见的人看"的定时器 */
2970
+ /* 絮叨和踱步停掉(它们本来就只给看得见的人看),但**轮询放慢而不是停掉** ——
2971
+ 值班的前提是还看得见:你走开时跑完的那几轮、出的错,都得记下来。 */
2884
2972
  paused = true
2973
+ awayWatch = { runs: 0, errors: 0, longestMs: 0, at: awayAt }
2885
2974
  syncPoll()
2886
2975
  syncChatter()
2887
2976
  syncWander()
2977
+ if (pollHandle) { clock.cancel(pollHandle); pollHandle = null }
2978
+ pollHandle = clock.every(HIDDEN_POLL_MS, poll)
2888
2979
  return
2889
2980
  }
2890
2981
  /* 回来了:先恢复再算问候 —— 恢复是必须的,问候才是锦上添花 */
@@ -2896,9 +2987,19 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2896
2987
  }
2897
2988
  if (awayAt === 0) return
2898
2989
  const away = nowMs() - awayAt
2990
+ const watch = awayWatch
2991
+ awayWatch = null
2899
2992
  awayAt = 0
2900
2993
  if (!(cfg.backAfterMs > 0) || away < cfg.backAfterMs) return
2901
2994
  wake(true)
2995
+ /* 有账就报账(这才是"值班"的意义);没账才是普通的那句问候。 */
2996
+ if (watch !== null && (watch.runs > 0 || watch.errors > 0)) {
2997
+ const bits = []
2998
+ if (watch.runs > 0) bits.push(pick(LINES.awayRuns).split('{n}').join(String(watch.runs)))
2999
+ if (watch.errors > 0) bits.push(pick(LINES.awayErrors).split('{n}').join(String(watch.errors)))
3000
+ say(pick(LINES.awayOpen) + bits.join(''))
3001
+ return
3002
+ }
2902
3003
  say(pick(away >= BACK_LONG_MS ? LINES.backLong : LINES.backSoon))
2903
3004
  }
2904
3005
  if (typeof document.addEventListener === 'function') {
@@ -3130,6 +3231,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3130
3231
  /* 专注计时器:脚本与测试都能驱动 */
3131
3232
  startFocus,
3132
3233
  stopFocus,
3234
+ saySummary,
3235
+ get today() { return { ...watchToday() } },
3133
3236
  get focus() { return focusState() },
3134
3237
  openAbout: () => aboutDialog(),
3135
3238
  openActions: () => actionsDialog(),
@@ -3260,11 +3363,19 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3260
3363
  },
3261
3364
  streak: streakOf(memory.days),
3262
3365
  awaySince: Math.round(awayAt),
3366
+ /* 今天记了什么 —— 不用猜"她为什么说今天跑了 7 轮" */
3367
+ today: { ...watchToday() },
3368
+ /* 走开期间的值班记录(不在值班时为 null) */
3369
+ watch: awayWatch === null ? null : { ...awayWatch, forMs: Math.round(nowMs() - awayWatch.at) },
3370
+ /* 单轮跑了多久、有没有已经提过 —— 断言失败时能自己说出原因,不用猜 */
3371
+ runInFlightMs: busySince > 0 && lastBusy ? Math.round(nowMs() - busySince) : 0,
3372
+ longRunSaid: longRunSaidAt > 0,
3263
3373
  /* 专注:不用猜"她为什么一直站在干活中" */
3264
3374
  focus: focusState(),
3265
3375
  /* 定时器是否挂着;页面藏起来时三个都该是 false */
3266
3376
  timers: {
3267
- poll: pollHandle !== null, chatter: chatterHandle !== null,
3377
+ poll: pollHandle !== null,
3378
+ pollMs: paused ? HIDDEN_POLL_MS : cfg.pollMs, chatter: chatterHandle !== null,
3268
3379
  wander: wanderHandle !== null, paused,
3269
3380
  },
3270
3381
  /* 今日轮次:不用猜"她为什么突然得意",数字就在这里 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wisp",
3
- "version": "1.34.1",
3
+ "version": "1.35.0",
4
4
  "description": "DeepSeek娘 — an unofficial floating desktop companion for the DeepSeek Harness UI. Zero dependencies; sprites inlined as data URIs; schedules on the Client timer service instead of the trapped browser timer globals.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/verify-wisp.mjs CHANGED
@@ -3430,8 +3430,12 @@ head('3y-3. the focus timer, and going quiet while the page is hidden')
3430
3430
  quiet.document.visibilityState = 'hidden'
3431
3431
  quiet.document.dispatch('visibilitychange', {})
3432
3432
  const timers = quietApi.doctor().timers
3433
- check(timers.paused === true && timers.poll === false && timers.chatter === false && timers.wander === false,
3434
- 'hiding the page stops all three timers', JSON.stringify(timers))
3433
+ /* 絮叨与踱步停掉(只给看得见的人看),但**轮询放慢而不是停掉** ——
3434
+ 值班的前提是还看得见:你走开时跑完的轮次和出的错,都要被记下来。 */
3435
+ check(timers.paused === true && timers.chatter === false && timers.wander === false,
3436
+ 'hiding the page stops the chatter and the wander', JSON.stringify(timers))
3437
+ check(timers.poll === true && timers.pollMs === 5000,
3438
+ 'but the poll keeps a slow watch instead of stopping (she is on duty)', JSON.stringify(timers))
3435
3439
 
3436
3440
  /* 藏起来期间:来了一堆报错,她也不该有任何反应(轮询停了) */
3437
3441
  quiet.state.errors = 3
@@ -3454,6 +3458,55 @@ head('3y-3. the focus timer, and going quiet while the page is hidden')
3454
3458
  quiet.state.busy = false
3455
3459
  quiet.advance(3000, 200)
3456
3460
  check(moodOf(quiet) === 'worried', 'and now the same errors do get a reaction', moodOf(quiet))
3461
+ /* ---- 值班:走开期间跑完的轮次与报错,回来要报账 ------------------------------ */
3462
+ const saidNow = () => quiet.all('wisp-say').filter((el) => el.removed !== true)
3463
+ .map((el) => el.textContent).join(' ')
3464
+ quiet.document.visibilityState = 'hidden'
3465
+ quiet.document.dispatch('visibilitychange', {})
3466
+ quiet.state.busy = true
3467
+ quiet.advance(60000, 5000) // 你走开之后,她看着一轮跑了一分钟
3468
+ quiet.state.errors = 5
3469
+ quiet.state.busy = false
3470
+ quiet.advance(180000, 5000) // 一共走开三分钟(超过"回来问候"的门槛)
3471
+ check(saidNow() === '', 'while you are away she takes notes but says nothing', saidNow())
3472
+
3473
+ quiet.document.visibilityState = 'visible'
3474
+ quiet.document.dispatch('visibilitychange', {})
3475
+ quiet.advance(1000, 200)
3476
+ const report = saidNow()
3477
+ check(/轮/.test(report) && /跑完/.test(report),
3478
+ 'coming back, she reports how many runs finished while you were away', report)
3479
+ check(/错/.test(report), 'and she mentions the errors she saw', report)
3480
+ const book = quietApi.doctor().today
3481
+ check(book.runs >= 1 && book.errors >= 1 && book.longestMs > 0,
3482
+ 'and the day book recorded the runs, the errors and the longest one',
3483
+ JSON.stringify(book))
3484
+
3485
+ /* ---- 单轮跑得异常久:她会中途说一声,而且同一轮只说一次 ---------------------- */
3486
+ quiet.state.busy = true
3487
+ /* 刚好推过"单轮跑太久"的阈值。推 11 分钟是不行的:她的话早就过期了(气泡有存活时间),
3488
+ 于是断言会看到空字符串 —— 断言本身没错,是喂给它的时间不对。 */
3489
+ /* 多推几步:busySince 是在推进开始后的第一个轮询才记下的,卡在 601000 会差 200ms。 */
3490
+ quiet.advance(604800, 1200)
3491
+ const longLine = saidNow()
3492
+ const during = quietApi.doctor()
3493
+ check(/分钟/.test(longLine), 'a single run that drags on gets a check-in',
3494
+ `她说的是「${longLine}」;runInFlight=${during.runInFlightMs} longRunSaid=${during.longRunSaid} paused=${during.timers.paused}`)
3495
+ quiet.state.busy = false
3496
+ quiet.advance(3000, 500)
3497
+
3498
+ /* ---- 今日小结:把她记着的东西一次说完 ---------------------------------------- */
3499
+ quietApi.saySummary()
3500
+ quiet.advance(200, 50)
3501
+ const summaryLine = saidNow()
3502
+ /* 词池里有三个变体(今天…/今天:…/这一天…),所以断言**内容**而不是某一个措辞 */
3503
+ check(/轮/.test(summaryLine) && /错/.test(summaryLine) && /分钟/.test(summaryLine),
3504
+ 'the daily summary reads back what she has been tracking', summaryLine)
3505
+ check(/\d/.test(summaryLine), 'with real numbers', summaryLine)
3506
+ check(summaryLine.indexOf('{') < 0, 'and no placeholder is left unfilled', summaryLine)
3507
+ check(typeof quietApi.today.runs === 'number' && quietApi.today.runs >= 1,
3508
+ 'the day book is also readable from the api', JSON.stringify(quietApi.today))
3509
+
3457
3510
  quiet.win.__wisp.destroy()
3458
3511
  active = keepQuiet
3459
3512
  }