clauddy 1.24.0 → 1.25.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
@@ -109,6 +109,35 @@ Other activities — **planning**, **researching**, **delegating**, **waiting**
109
109
  show up in the status line as they happen. When Claude goes quiet, the pet drops
110
110
  back to plain **working** / **idle**.
111
111
 
112
+ ## The pet talks
113
+
114
+ Now and then the pet says something in a speech bubble. It only speaks when something changes, never on a timer, and it keeps quiet when it has nothing specific to say. Remarks are at least 10 minutes apart; the big moments can cut that line.
115
+
116
+ | Moment | What it says (expanded) | Minimized | Blip |
117
+ | --- | --- | --- | --- |
118
+ | First launch of the day | _Good morning! Yesterday was your heaviest day this week, 12M tokens._ | _Morning! Big day yesterday._ | ✓ |
119
+ | Session crosses your fire threshold | _Getting warm, 91% already. At this pace you'll run out in about 40m._ | _91%, getting warm!_ | ✓ |
120
+ | The session window resets | _Fresh window! That was 34M tokens over 4h 12m, mostly editing code._ | _Fresh window! 34M last time._ | ✓ |
121
+ | The session hits 100% | _That's the limit. I'll be back at 2:35 PM._ | _Maxed out till 2:35 PM._ | |
122
+ | You're back after 2+ hours away | _Welcome back! You were gone 3h 12m. The session's at 12%._ | _Welcome back!_ | |
123
+ | 90 minutes of unbroken work | _You've been at it for 1h 35m straight. Stretch break?_ | _Stretch break?_ | |
124
+ | A new record day (30-day best) | _New record! 45M tokens today, your biggest day in a month._ | _New record: 45M!_ | |
125
+ | Codex crosses fire, hits 100%, or resets | _Codex is at 91% now. It resets in 1h 12m._ | _Codex at 91%!_ | ✓ (fire, reset) |
126
+
127
+ The greeting follows your clock (_Good morning_, _Good afternoon_, _Good evening_, or _Still up?_ before 5 AM) and appears once a day, and so does the record. The reset recap covers only what the pet actually saw, so if you launch it mid-session it says less. Click the bubble to dismiss it.
128
+
129
+ ### The voice
130
+
131
+ Optionally the pet also **blips** as it talks: short chiptune notes, one per letter, generated live with Web Audio (square waves, no sound files). It blips faster and higher when it's on fire, slower and lower when it's sleepy. Only the headline moments blip (the ✓ rows above), because a sound on every remark wears out fast.
132
+
133
+ Audio should never catch you off guard in a meeting:
134
+
135
+ - **Off by default.** Turn it on under **⚙ Settings → voice → Chiptune voice** (you'll hear a sample).
136
+ - **One-click mute.** While the voice is on, a 🔊 button sits in the title bar. Click it to mute for an hour, and again to unmute.
137
+ - **Always silent** in menu-bar mode and while the pet is minimized.
138
+
139
+ **Speech bubbles** can be switched off entirely in the same place. Try any remark from the terminal with `bunx clauddy say <kind>` ([see below](#play-with-the-pet)).
140
+
112
141
  ## Install
113
142
 
114
143
  **macOS (Apple Silicon)** is the first-class build. **Windows (x64)** and **Linux (x64)** work too. The `bunx`/`npx` route below runs on all of them today.
@@ -232,6 +261,9 @@ Settings saved from the UI live in `~/.claude-usage-monitor/config.json`, so you
232
261
  "activeThresholdMs": 20000, // "active" if Claude wrote to its logs within this window
233
262
  "sleepThresholdMs": 300000, // "sleeping" after this much idle time (5 min)
234
263
  "codex": { "enabled": false }, // set by Settings → Codex → Connect
264
+ "talk": true, // speech bubbles on transitions
265
+ "sound": false, // chiptune blips, opt-in
266
+ "soundMutedUntil": 0, // set by the 🔊 button: muted until this epoch ms
235
267
  }
236
268
  ```
237
269
 
@@ -248,6 +280,8 @@ bunx clauddy working # 🍴 eats token coins
248
280
  bunx clauddy tired # 🥵 maxed out
249
281
  bunx clauddy idle # 🙂 calm
250
282
  bunx clauddy auto # ↩️ back to your real usage
283
+ bunx clauddy say # 💬 a remark in the speech bubble
284
+ bunx clauddy say fire # or a specific one: greeting, fire, reset, maxed, welcome, streak, record, codex
251
285
  ```
252
286
 
253
287
  Each state is written to the data dir the running widget watches, so it reacts
@@ -259,7 +293,7 @@ live. (Installed globally? Drop the `bunx`: `clauddy poke`. Working on the repo?
259
293
  - **`main.js`** — Electron main process: frameless, transparent, always-on-top window; polls usage; fires macOS notifications; watches `config.json` and `debug.json`.
260
294
  - **`usage.js`** — reads `~/.claude/projects/**/*.jsonl`, sums tokens per model/project/day, detects the rolling 5-hour session window, the working/sleeping status, and which activity (reading/editing/running/…) Claude is on from its latest tool use.
261
295
  - **`auth.js`** — OAuth login (PKCE, same public client as Claude Code) that fetches the authoritative usage %. Token stored locally, never committed.
262
- - **`renderer/`** — the pet itself: an SVG pixel sprite, CSS animations, and the Web Animations API for particles.
296
+ - **`renderer/`** — the pet itself: an SVG pixel sprite, CSS animations, and the Web Animations API for particles. `voice.js` writes its remarks and synthesizes the blips.
263
297
  - **`make-icon.js`** — generates the app icon from the pixel sprite (`build/icon.icns`).
264
298
 
265
299
  ## Data & privacy
package/bin/clauddy.js CHANGED
@@ -20,6 +20,7 @@ const STATES = [
20
20
  'idle',
21
21
  'poke',
22
22
  'celebrate',
23
+ 'say',
23
24
  'auto',
24
25
  'clear',
25
26
  // activity states (what Claude Code is doing)
@@ -31,7 +32,10 @@ const STATES = [
31
32
  'delegating',
32
33
  'waiting',
33
34
  ]
35
+ // `clauddy say <kind>` previews one of the pet's remarks
36
+ const REMARKS = ['greeting', 'fire', 'reset', 'maxed', 'welcome', 'streak', 'record', 'codex']
34
37
  const arg = process.argv[2]
38
+ const kind = arg === 'say' ? process.argv[3] || 'greeting' : undefined
35
39
 
36
40
  if (!arg) {
37
41
  // no argument → launch the app
@@ -47,19 +51,25 @@ if (!arg) {
47
51
  'Usage:',
48
52
  ' clauddy launch the widget',
49
53
  ' clauddy <state> poke the running widget (just for fun)',
54
+ ' clauddy say [kind] make the pet say one of its remarks',
50
55
  '',
51
56
  'States: fire, sleeping, working, tired, idle, poke, celebrate, auto',
57
+ `Remarks: ${REMARKS.join(', ')}`,
52
58
  '(the widget must be running for a state to show)',
53
59
  ].join('\n'),
54
60
  )
61
+ } else if (kind && !REMARKS.includes(kind)) {
62
+ console.error(`clauddy: unknown remark "${kind}"`)
63
+ console.error(`try: ${REMARKS.join(', ')}`)
64
+ process.exit(1)
55
65
  } else if (STATES.includes(arg)) {
56
66
  // write the state for the running app to pick up
57
67
  fs.mkdirSync(dataDir, { recursive: true })
58
68
  fs.writeFileSync(
59
69
  path.join(dataDir, 'debug.json'),
60
- `${JSON.stringify({ state: arg, t: Date.now() })}\n`,
70
+ `${JSON.stringify({ state: arg, kind, t: Date.now() })}\n`,
61
71
  )
62
- console.log(`clauddy → ${arg} (the running widget will react)`)
72
+ console.log(`clauddy → ${arg}${kind ? ` ${kind}` : ''} (the running widget will react)`)
63
73
  } else {
64
74
  console.error(`clauddy: unknown command "${arg}"`)
65
75
  console.error('try: fire, sleeping, working, tired, idle, poke, celebrate, auto — or --help')
package/main.js CHANGED
@@ -77,6 +77,9 @@ function publicConfig(c) {
77
77
  fireThreshold: c.fireThreshold,
78
78
  zoom: c.zoom,
79
79
  codex: !!c.codex?.enabled,
80
+ talk: c.talk,
81
+ sound: c.sound,
82
+ soundMutedUntil: c.soundMutedUntil,
80
83
  }
81
84
  }
82
85
 
@@ -92,6 +95,9 @@ function loadConfig() {
92
95
  fireThreshold: 90, // session % at which the pet catches fire (tired still fixed at 100)
93
96
  zoom: 100, // widget scale %, 100-200
94
97
  codex: { enabled: false }, // opt-in from Settings, never on by finding ~/.codex
98
+ talk: true, // speech bubbles on transitions
99
+ sound: false, // chiptune blips: opt-in, audio must never surprise anyone
100
+ soundMutedUntil: 0, // the one-click "mute for 1 hour", as an epoch ms
95
101
  pollIntervalMs: 4000,
96
102
  activeThresholdMs: 20000,
97
103
  sleepThresholdMs: 300000,
@@ -379,6 +385,9 @@ function createWindow() {
379
385
  preload: path.join(__dirname, 'preload.js'),
380
386
  contextIsolation: true,
381
387
  nodeIntegration: false,
388
+ // the pet's blips start from a usage change, never from a click, so
389
+ // Chromium would otherwise hold the AudioContext suspended
390
+ autoplayPolicy: 'no-user-gesture-required',
382
391
  },
383
392
  })
384
393
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clauddy",
3
3
  "desktopName": "clauddy.desktop",
4
- "version": "1.24.0",
4
+ "version": "1.25.0",
5
5
  "description": "A cute desktop pet that tracks your Claude Code usage",
6
6
  "main": "main.js",
7
7
  "bin": {
@@ -33,6 +33,14 @@
33
33
  <circle cx="7" cy="7" r="3" />
34
34
  </svg>
35
35
  </button>
36
+ <button id="mute" title="Mute the voice for 1 hour" hidden>
37
+ <svg class="ic ic-sound" viewBox="0 0 24 24" aria-hidden="true">
38
+ <path d="M11 5 6 9H2v6h4l5 4Z" /><path d="M15.5 8.5a5 5 0 0 1 0 7M19 5a10 10 0 0 1 0 14" />
39
+ </svg>
40
+ <svg class="ic ic-muted" viewBox="0 0 24 24" aria-hidden="true">
41
+ <path d="M11 5 6 9H2v6h4l5 4Z" /><path d="m22 9-6 6M16 9l6 6" />
42
+ </svg>
43
+ </button>
36
44
  <button id="usage" title="Open Usage page">
37
45
  <svg class="ic" viewBox="0 0 24 24" aria-hidden="true">
38
46
  <path d="M7 7h10v10M7 17 17 7" />
@@ -146,6 +154,8 @@
146
154
  <div id="sweat"><i></i><i></i></div>
147
155
  <div id="dropzone"></div>
148
156
  </div>
157
+ <!-- the pet's remarks: only on transitions, never on a timer -->
158
+ <div id="bubble" role="status" aria-live="polite" hidden><span id="bubble-text"></span></div>
149
159
  <!-- editing mode, foreground: coffee mug in front of the pet -->
150
160
  <!-- mug: its own SVG with UNIFORM scaling (no stage stretch) so it stays perfectly shaped -->
151
161
  <svg id="mug" viewBox="0 0 46 44" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
@@ -197,10 +207,10 @@
197
207
  <!-- Dual-service dock: two glanceable readings, one shared pet. -->
198
208
  <div id="mini-dual">
199
209
  <button type="button" class="mini-source" data-provider="claude">
200
- <span>Claude</span><span class="mini-track"><i id="mini-claude-bar"></i></span><b id="mini-claude-value">—</b>
210
+ <svg class="mini-logo" viewBox="0 0 24 24" aria-hidden="true"><use href="#logo-claude" /></svg><span class="mini-source-name">Claude</span><span class="mini-track"><i id="mini-claude-bar"></i></span><b id="mini-claude-value">—</b>
201
211
  </button>
202
212
  <button type="button" class="mini-source" data-provider="codex">
203
- <span>Codex</span><span class="mini-track"><i id="mini-codex-bar"></i></span><b id="mini-codex-value">—</b>
213
+ <svg class="mini-logo" viewBox="0 0 24 24" aria-hidden="true"><use href="#logo-openai" /></svg><span class="mini-source-name">Codex</span><span class="mini-track"><i id="mini-codex-bar"></i></span><b id="mini-codex-value">—</b>
204
214
  </button>
205
215
  </div>
206
216
  <div id="mini-reset" hidden></div>
@@ -380,6 +390,15 @@
380
390
  <span class="set-row-label">Zoom</span>
381
391
  <span class="num"><button type="button" class="num-btn" data-for="set-zoom" data-step="-25">−</button><span class="num-val"><input id="set-zoom" type="number" min="100" max="200" step="25" /><i>%</i></span><button type="button" class="num-btn" data-for="set-zoom" data-step="25">+</button></span>
382
392
  </div>
393
+ <div class="settings-label threshold-label">voice</div>
394
+ <div class="set-row">
395
+ <span class="set-row-label">Speech bubbles</span>
396
+ <input id="set-talk" class="set-toggle" type="checkbox" aria-label="Let the pet talk" />
397
+ </div>
398
+ <div class="set-row voice-sub">
399
+ <span class="set-row-label">Chiptune voice</span>
400
+ <input id="set-sound" class="set-toggle" type="checkbox" aria-label="Play blips when the pet talks" />
401
+ </div>
383
402
  </div>
384
403
 
385
404
  <div class="set-actions">
@@ -398,6 +417,7 @@
398
417
  </div>
399
418
  </div>
400
419
  <script src="burn.js"></script>
420
+ <script src="voice.js"></script>
401
421
  <script src="pet.js"></script>
402
422
  </body>
403
423
  </html>
package/renderer/pet.js CHANGED
@@ -471,6 +471,276 @@ function celebrate() {
471
471
  for (let i = 0; i < 24; i++) spawnConfetti(i)
472
472
  }
473
473
 
474
+ // ---- voice ------------------------------------------------------------------
475
+ // The bubble speaks on transitions only, at most once per REMARK_GAP_MS. The
476
+ // headline moments (the day's first hello, a fresh window, catching fire) may
477
+ // jump that queue, and only they blip: a noise on every remark wears out far
478
+ // faster than the remark itself.
479
+ const REMARK_GAP_MS = 10 * 60000
480
+ const MUTE_MS = 60 * 60000
481
+ const blipper = Voice.createBlipper(() => {
482
+ const AC = globalThis.AudioContext || globalThis.webkitAudioContext
483
+ return AC ? new AC() : null
484
+ })
485
+ let lastRemarkAt = 0
486
+ let bubbleTimer = null
487
+
488
+ const talkOn = () => currentConfig.talk !== false
489
+ const mutedNow = () => currentConfig.soundMutedUntil > Date.now()
490
+ // opt-in, and never where it would surprise someone: the menu-bar popover, the
491
+ // mini face, or the hour after a mute
492
+ function soundOn() {
493
+ if (!currentConfig.sound || currentConfig.mode === 'menubar') return false
494
+ if (document.body.classList.contains('collapsed')) return false
495
+ return !mutedNow()
496
+ }
497
+
498
+ function moodOf(st) {
499
+ return st === 'stressed' ? 'fire' : st === 'sleeping' || st === 'tired' ? 'sleepy' : 'normal'
500
+ }
501
+
502
+ // `line` is a remark from voice.js ({ text, short }) or a plain string. The
503
+ // mini face has room for a glance, not a sentence, so it gets the short one.
504
+ // `force` is the simulator's: it skips the gates but still respects the mute
505
+ function say(line, { headline = false, mood = 'normal', force = false } = {}) {
506
+ if (!line) return false
507
+ const collapsed = document.body.classList.contains('collapsed')
508
+ const text = typeof line === 'string' ? line : collapsed ? line.short : line.text
509
+ if (!force) {
510
+ const b = document.body.classList
511
+ if (!talkOn() || b.contains('settings-open')) return false
512
+ if (!headline && Date.now() - lastRemarkAt < REMARK_GAP_MS) return false
513
+ }
514
+ lastRemarkAt = Date.now()
515
+ const bubble = el('bubble')
516
+ el('bubble-text').textContent = text
517
+ bubble.classList.remove('leaving')
518
+ placeBubble()
519
+ bubble.hidden = false
520
+ fitSize() // collapsed, the bubble is in the flow: the window grows to fit it
521
+ clearTimeout(bubbleTimer)
522
+ bubbleTimer = setTimeout(hideBubble, Math.max(4500, text.length * 70 + 2500))
523
+ // the mouth moves along, whether or not there's sound
524
+ for (let i = 0; i < Math.min(4, Math.ceil(text.length / 18)); i++) setTimeout(chomp, i * 260)
525
+ if ((headline || force) && soundOn()) blipper.play(text, mood)
526
+ return true
527
+ }
528
+
529
+ // expanded, the bubble floats over the scene beside the pet; collapsed there's
530
+ // no room beside it, so it moves above the pet, into the card's flow
531
+ function placeBubble() {
532
+ const bubble = el('bubble')
533
+ const stage = el('stage')
534
+ if (document.body.classList.contains('collapsed')) {
535
+ if (bubble.nextElementSibling !== stage) stage.before(bubble)
536
+ } else if (bubble.parentElement !== stage) stage.appendChild(bubble)
537
+ }
538
+
539
+ function hideBubble() {
540
+ clearTimeout(bubbleTimer)
541
+ const bubble = el('bubble')
542
+ if (bubble.hidden) return
543
+ bubble.classList.add('leaving')
544
+ bubbleTimer = setTimeout(() => {
545
+ bubble.hidden = true
546
+ bubble.classList.remove('leaving')
547
+ fitSize()
548
+ }, 180)
549
+ }
550
+ el('bubble').addEventListener('click', (e) => {
551
+ e.stopPropagation()
552
+ hideBubble()
553
+ })
554
+
555
+ // once-a-day remarks, keyed by local date; memory is the fallback when storage
556
+ // isn't there, so a broken profile can't make the pet repeat itself
557
+ const toldMemo = {}
558
+ const dayKey = () => new Date().toDateString()
559
+ function toldToday(key) {
560
+ try {
561
+ if (localStorage.getItem(key) === dayKey()) return true
562
+ } catch {}
563
+ return toldMemo[key] === dayKey()
564
+ }
565
+ function markToday(key) {
566
+ toldMemo[key] = dayKey()
567
+ try {
568
+ localStorage.setItem(key, dayKey())
569
+ } catch {}
570
+ }
571
+
572
+ // what the pet has noticed so far — the transitions are all relative to it
573
+ const AWAY_MS = 2 * 3600000
574
+ const STREAK_MS = 90 * 60000
575
+ const STREAK_GAP_MS = 10 * 60000
576
+ let greeted = false
577
+ let heardConfig = false
578
+ let lastLivePct = null // Claude's session %, null until a live reading lands
579
+ let awayMs = 0
580
+ let streakFrom = 0
581
+ let lastWorkAt = 0
582
+ let streakTold = false
583
+
584
+ // what the current Claude window has been like, for the recap when it closes
585
+ // (#31). The peak is the session's tokens just before the rollover — after it
586
+ // the counter starts over. Only the time the pet actually watched counts.
587
+ const TALLY_GAP_MS = 15000 // a longer gap is the machine asleep, not work
588
+ let tally = { peak: 0, activeMs: 0, acts: {}, at: 0 }
589
+ function noteSession(d) {
590
+ const now = Date.now()
591
+ const dt = tally.at ? Math.min(now - tally.at, TALLY_GAP_MS) : 0
592
+ tally.at = now
593
+ if (d.active) {
594
+ tally.activeMs += dt
595
+ if (d.activity) tally.acts[d.activity] = (tally.acts[d.activity] || 0) + dt
596
+ }
597
+ tally.peak = Math.max(tally.peak, d.session?.tokens || 0)
598
+ }
599
+ function takeRecap(was) {
600
+ const top = Object.entries(tally.acts).sort((a, b) => b[1] - a[1])[0]?.[0] || null
601
+ const r = { was, tokens: tally.peak, activeMs: tally.activeMs, top }
602
+ tally = { peak: 0, activeMs: 0, acts: {}, at: tally.at }
603
+ return r
604
+ }
605
+
606
+ // Codex, followed whichever tab is on screen. Its logs only move while it
607
+ // runs, so a stale reading keeps the baseline: the next fresh one is compared
608
+ // against the last number actually seen.
609
+ let lastCodexPct = null
610
+ function listenCodex() {
611
+ const c = codexData
612
+ if (!codexOn() || !c) {
613
+ lastCodexPct = null
614
+ return
615
+ }
616
+ const pct = c.session?.pct
617
+ if (pct == null || codexStale(c)) return
618
+ const was = lastCodexPct
619
+ lastCodexPct = pct
620
+ if (was == null) return
621
+ const fireAt = currentConfig.fireThreshold ?? 90
622
+ const reset = c.session.resetMs
623
+ if (was - pct > 25) say(Voice.codexResetLine(was), { headline: true })
624
+ else if (was < 100 && pct >= 100)
625
+ say(Voice.codexMaxedLine(fmtResetClock(reset)), { mood: 'sleepy' })
626
+ else if (was < fireAt && pct >= fireAt && pct < 100)
627
+ say(Voice.codexFireLine(pct, reset, fmtReset), { headline: true, mood: 'fire' })
628
+ }
629
+
630
+ // called by paint() with the state it just drew. The Claude remarks follow the
631
+ // Claude view; Codex has its own, above.
632
+ function listen(before, st, { isCodex, liveOn, sp, sessReset, proj }) {
633
+ // the saved config decides whether it talks at all, and it lands just after
634
+ // the first usage tick: speaking before it would ignore a "talk: false"
635
+ if (!heardConfig) return
636
+ const d = lastData
637
+ const mood = moodOf(st)
638
+ if (!greeted) {
639
+ greeted = true
640
+ if (!toldToday('clauddy.greeted')) {
641
+ markToday('clauddy.greeted')
642
+ const line = Voice.greetingLine(d.days30, new Date().getHours(), fmtTokens)
643
+ if (say(line, { headline: true, mood })) return
644
+ }
645
+ }
646
+ listenCodex()
647
+ if (isCodex) return
648
+ const now = Date.now()
649
+
650
+ if (liveOn) {
651
+ const fireAt = currentConfig.fireThreshold ?? 90
652
+ const was = lastLivePct
653
+ lastLivePct = sp
654
+ // a first reading is a baseline: launching at 95% is not "catching fire"
655
+ if (was != null && was < 100 && sp >= 100) {
656
+ say(Voice.maxedLine(fmtResetClock(sessReset)), { mood: 'sleepy' })
657
+ } else if (was != null && was < fireAt && sp >= fireAt && sp < 100) {
658
+ const eta = proj?.kind === 'eta' ? proj.ms : null
659
+ say(Voice.fireLine(sp, eta, sessReset, fmtReset), { headline: true, mood: 'fire' })
660
+ }
661
+ } else {
662
+ lastLivePct = null // logged out, or switching: the next reading starts over
663
+ }
664
+
665
+ if (st === 'sleeping' && Number.isFinite(d.lastActivityMs))
666
+ awayMs = Math.max(awayMs, d.lastActivityMs)
667
+ else if (before === 'sleeping') {
668
+ if (awayMs >= AWAY_MS) say(Voice.welcomeLine(awayMs, liveOn ? sp : null, fmtReset))
669
+ awayMs = 0
670
+ }
671
+
672
+ if (st === 'working') {
673
+ if (!streakFrom || now - lastWorkAt > STREAK_GAP_MS) {
674
+ streakFrom = now
675
+ streakTold = false
676
+ }
677
+ lastWorkAt = now
678
+ if (!streakTold && now - streakFrom >= STREAK_MS)
679
+ streakTold = say(Voice.streakLine(now - streakFrom, fmtReset))
680
+ }
681
+
682
+ const record = Voice.recordLine(d.days30, fmtTokens)
683
+ if (record && !toldToday('clauddy.record') && say(record, { mood })) markToday('clauddy.record')
684
+ }
685
+
686
+ // `./pet say <kind>` previews one remark — real numbers where there are some,
687
+ // plausible ones where the moment hasn't happened
688
+ function sampleLine(kind) {
689
+ const days = lastData?.days30?.length ? [...lastData.days30] : new Array(30).fill(0)
690
+ const pct = realUsage?.session?.pct
691
+ const reset = realUsage?.session?.resetMs ?? 72 * 60000
692
+ if (kind === 'fire') return Voice.fireLine(Math.max(pct ?? 0, 91), 40 * 60000, reset, fmtReset)
693
+ if (kind === 'reset') {
694
+ const t = tally.peak ? tally : { peak: 34e6, activeMs: 4 * 3600000 + 12 * 60000, acts: {} }
695
+ const top = Object.entries(t.acts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'editing'
696
+ return Voice.recapLine(
697
+ { was: 92, tokens: t.peak, activeMs: t.activeMs, top },
698
+ fmtTokens,
699
+ fmtReset,
700
+ )
701
+ }
702
+ if (kind === 'codex')
703
+ return Voice.codexFireLine(91, codexData?.session?.resetMs ?? reset, fmtReset)
704
+ if (kind === 'maxed') return Voice.maxedLine(fmtResetClock(reset))
705
+ if (kind === 'welcome') return Voice.welcomeLine(3 * 3600000 + 12 * 60000, pct ?? null, fmtReset)
706
+ if (kind === 'streak') return Voice.streakLine(95 * 60000, fmtReset)
707
+ if (kind === 'record') {
708
+ days[days.length - 1] = Math.max(2e6, Math.max(...days.slice(0, -1)) * 1.15)
709
+ return Voice.recordLine(days, fmtTokens)
710
+ }
711
+ if (!days[days.length - 2]) days[days.length - 2] = 12e6 // no yesterday yet: pretend one
712
+ return Voice.greetingLine(days, new Date().getHours(), fmtTokens)
713
+ }
714
+ function sayDebug(kind) {
715
+ const st = [...document.body.classList].find((c) => c.startsWith('state-'))?.slice(6)
716
+ const mood =
717
+ kind === 'fire' || kind === 'codex' ? 'fire' : kind === 'maxed' ? 'sleepy' : moodOf(st)
718
+ return say(sampleLine(kind), { force: true, mood })
719
+ }
720
+
721
+ // mute sits in the title bar while the voice is on: one click before a call
722
+ let unmuteTimer = null
723
+ function paintMute() {
724
+ const btn = el('mute')
725
+ // menu-bar mode is always silent, so there is nothing there to mute
726
+ btn.hidden = !(currentConfig.sound && talkOn()) || currentConfig.mode === 'menubar'
727
+ document.body.classList.toggle('has-mute', !btn.hidden) // the chip makes room for it
728
+ const muted = mutedNow()
729
+ btn.classList.toggle('muted', muted)
730
+ btn.title = muted
731
+ ? `Muted until ${new Date(currentConfig.soundMutedUntil).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} — click to unmute`
732
+ : 'Mute the voice for 1 hour'
733
+ clearTimeout(unmuteTimer)
734
+ if (muted) unmuteTimer = setTimeout(paintMute, currentConfig.soundMutedUntil - Date.now() + 50)
735
+ }
736
+ el('mute').addEventListener('click', (e) => {
737
+ e.stopPropagation()
738
+ const until = mutedNow() ? 0 : Date.now() + MUTE_MS
739
+ currentConfig = { ...currentConfig, soundMutedUntil: until } // repaint now, main confirms
740
+ paintMute()
741
+ window.api.saveConfig({ soundMutedUntil: until })
742
+ })
743
+
474
744
  // ---- services ---------------------------------------------------------------
475
745
  // Claude and Codex are monitored side by side, but the panel shows one at a
476
746
  // time: the tabs under the pet pick which one owns the chip, the pet, the
@@ -635,6 +905,7 @@ const prevPct = {} // per service: a switch must not read as a reset
635
905
  let lastData = null
636
906
  function render(d) {
637
907
  lastData = d
908
+ noteSession(d)
638
909
  // the burn trail is Claude's, fed on every Claude tick whatever is on screen
639
910
  const cl = realUsage
640
911
  burn.note(d.session.tokens, !!cl && cl.session.resetMs != null)
@@ -697,6 +968,7 @@ function paint() {
697
968
  for (const a of acts) {
698
969
  document.body.classList.toggle(`act-${a}`, st === 'working' && curActivity === a)
699
970
  }
971
+ const before = prevState
700
972
  if (prevState && prevState !== st) {
701
973
  if (st === 'sleeping') oneShot('drowse', 700)
702
974
  else if (prevState === 'sleeping') oneShot('wake', 700)
@@ -721,7 +993,11 @@ function paint() {
721
993
  : `${fmtTokens(d.today.tokens)} tokens today`
722
994
 
723
995
  const was = prevPct[view]
724
- if (liveOn && was != null && sessActive && sessPct != null && was - sessPct > 25) celebrate()
996
+ if (liveOn && was != null && sessActive && sessPct != null && was - sessPct > 25) {
997
+ celebrate()
998
+ // Codex's own reset is called out by listenCodex, on screen or not
999
+ if (!isCodex) say(Voice.recapLine(takeRecap(was), fmtTokens, fmtReset), { headline: true })
1000
+ }
725
1001
  prevPct[view] = sessPct
726
1002
  el('session-pct').textContent = pctText(liveOn ? sessPct : 0)
727
1003
  setLevel(el('session-pct'), liveOn ? sp : 0)
@@ -786,6 +1062,7 @@ function paint() {
786
1062
  stopEating()
787
1063
  }
788
1064
 
1065
+ listen(before, st, { isCodex, liveOn, sp, sessReset, proj })
789
1066
  fitSize()
790
1067
  }
791
1068
 
@@ -824,6 +1101,7 @@ window.api.onDebugState((o) => {
824
1101
  const s = o?.state
825
1102
  if (s === 'poke') return pokePet()
826
1103
  if (s === 'celebrate') return celebrate()
1104
+ if (s === 'say') return sayDebug(o.kind)
827
1105
  debugState = s === 'auto' || s === 'clear' || !s ? null : s
828
1106
  if (lastData) render(lastData)
829
1107
  })
@@ -837,6 +1115,8 @@ window.api.onConfig((cfg) => {
837
1115
  document.body.classList.toggle('is-menubar', currentConfig.mode === 'menubar')
838
1116
  document.body.classList.toggle('codex-on', !!currentConfig.codex)
839
1117
  if (!currentConfig.codex) codexData = null
1118
+ heardConfig = true
1119
+ paintMute()
840
1120
  paint()
841
1121
  applyZoom(currentConfig.zoom)
842
1122
  fitSize()
@@ -1307,6 +1587,8 @@ function snapshotSettings() {
1307
1587
  t2: el('set-t2').value,
1308
1588
  fire: el('set-fire').value,
1309
1589
  zoom: el('set-zoom').value,
1590
+ talk: el('set-talk').checked,
1591
+ sound: el('set-sound').checked,
1310
1592
  })
1311
1593
  }
1312
1594
  function refreshSaveDirty() {
@@ -1325,6 +1607,9 @@ function clearSaveDirty() {
1325
1607
  function reflectAlertsOn() {
1326
1608
  el('set-rows').classList.toggle('alerts-off', !el('set-alerts').checked)
1327
1609
  }
1610
+ function reflectTalkOn() {
1611
+ el('set-rows').classList.toggle('talk-off', !el('set-talk').checked)
1612
+ }
1328
1613
  // zoom applies as you step it, so the widget shows the size right away;
1329
1614
  // Cancel puts the saved value back
1330
1615
  function previewZoom() {
@@ -1344,9 +1629,13 @@ function populateSettings() {
1344
1629
  el('set-t2').value = th[1] != null ? th[1] : 95
1345
1630
  el('set-fire').value = c.fireThreshold != null ? c.fireThreshold : 90
1346
1631
  el('set-zoom').value = c.zoom != null ? c.zoom : 100
1632
+ el('set-talk').checked = c.talk !== false
1633
+ el('set-sound').checked = !!c.sound
1634
+ reflectTalkOn()
1347
1635
  clearSaveDirty() // fields now match the saved config
1348
1636
  }
1349
1637
  function openSettings() {
1638
+ hideBubble()
1350
1639
  document.body.classList.remove('collapsed')
1351
1640
  populateSettings()
1352
1641
  document.body.classList.add('settings-open')
@@ -1388,11 +1677,24 @@ for (const b of document.querySelectorAll('.num-btn')) {
1388
1677
  })
1389
1678
  }
1390
1679
  // light up Save whenever an editable field changes
1391
- for (const id of ['set-alerts', 'set-t1', 'set-t2', 'set-fire', 'set-zoom']) {
1680
+ for (const id of [
1681
+ 'set-alerts',
1682
+ 'set-t1',
1683
+ 'set-t2',
1684
+ 'set-fire',
1685
+ 'set-zoom',
1686
+ 'set-talk',
1687
+ 'set-sound',
1688
+ ]) {
1392
1689
  el(id).addEventListener('input', refreshSaveDirty)
1393
1690
  el(id).addEventListener('change', refreshSaveDirty)
1394
1691
  }
1395
1692
  el('set-alerts').addEventListener('change', reflectAlertsOn)
1693
+ el('set-talk').addEventListener('change', reflectTalkOn)
1694
+ // turning the voice on plays a sample, so you hear what you just agreed to
1695
+ el('set-sound').addEventListener('change', () => {
1696
+ if (el('set-sound').checked) blipper.play('Hello there!')
1697
+ })
1396
1698
  el('set-zoom').addEventListener('input', previewZoom)
1397
1699
  // display-mode segmented control (floating pet | menu bar)
1398
1700
  for (const b of document.querySelectorAll('#set-mode .seg-btn')) {
@@ -1417,17 +1719,21 @@ el('set-save').addEventListener('click', () => {
1417
1719
  .sort((a, b) => a - b),
1418
1720
  fireThreshold: fire >= 1 && fire <= 99 ? fire : 90,
1419
1721
  zoom,
1722
+ talk: el('set-talk').checked,
1723
+ sound: el('set-sound').checked,
1420
1724
  })
1421
1725
  clearSaveDirty()
1422
1726
  document.body.classList.remove('settings-open')
1423
1727
  fitSize()
1424
1728
  })
1425
1729
  el('min').addEventListener('click', () => {
1730
+ hideBubble()
1426
1731
  document.body.classList.toggle('collapsed')
1427
1732
  fitSize()
1428
1733
  })
1429
1734
  // double-click the pet to collapse / expand
1430
1735
  el('pet').addEventListener('dblclick', () => {
1736
+ hideBubble()
1431
1737
  document.body.classList.toggle('collapsed')
1432
1738
  fitSize()
1433
1739
  })
@@ -1476,5 +1782,9 @@ if (typeof module === 'object' && module.exports) {
1476
1782
  paint,
1477
1783
  pickProvider,
1478
1784
  burn,
1785
+ say,
1786
+ hideBubble,
1787
+ soundOn,
1788
+ blipper,
1479
1789
  }
1480
1790
  }
@@ -1100,7 +1100,10 @@ body:not(.live) #bymodel {
1100
1100
  border: 1px solid rgba(206, 146, 109, 0.22);
1101
1101
  border-radius: 7px;
1102
1102
  background: rgba(206, 146, 109, 0.055);
1103
- padding: 10px 11px;
1103
+ /* the same breathing room the meters get, so it never touches the tabs
1104
+ above it or the Details rule below */
1105
+ margin: 12px 0;
1106
+ padding: 12px 13px;
1104
1107
  cursor: pointer;
1105
1108
  -webkit-app-region: no-drag;
1106
1109
  transition:
@@ -2429,13 +2432,21 @@ body.collapsed.dual #mini-acct-name {
2429
2432
  max-width: 112px;
2430
2433
  font-size: 10px;
2431
2434
  }
2435
+
2436
+ /* The mood sits beneath the pet, level with the selected source reset. */
2432
2437
  body.collapsed.dual .mini-line {
2433
- justify-content: flex-start;
2434
- margin: 0 0 5px 3px;
2438
+ position: absolute;
2439
+ bottom: 12px;
2440
+ left: 12px;
2441
+ width: 66px;
2442
+ justify-content: center;
2443
+ margin: 0;
2444
+ gap: 5px;
2435
2445
  }
2436
2446
  body.collapsed.dual #mini-text {
2437
- font-size: 10px;
2447
+ font-size: 9px;
2438
2448
  font-weight: 400;
2449
+ color: #b9afa3;
2439
2450
  }
2440
2451
  body.collapsed.dual #mini-dot {
2441
2452
  width: 4px;
@@ -2443,67 +2454,97 @@ body.collapsed.dual #mini-dot {
2443
2454
  }
2444
2455
  body.collapsed.dual #mini-dual {
2445
2456
  display: grid;
2446
- grid-template-columns: 1fr 1fr;
2447
- gap: 8px;
2457
+ grid-template-columns: 1fr;
2458
+ gap: 5px;
2448
2459
  }
2449
2460
  .mini-source {
2461
+ --src: #e4a17c;
2462
+ position: relative;
2450
2463
  display: grid;
2451
- grid-template-columns: 1fr;
2452
- gap: 3px;
2464
+ grid-template-columns: 12px minmax(0, 1fr) auto;
2465
+ align-items: center;
2466
+ column-gap: 5px;
2467
+ row-gap: 4px;
2453
2468
  min-width: 0;
2454
- padding: 4px 6px;
2469
+ min-height: 29px;
2470
+ padding: 3px 0 3px 5px;
2455
2471
  border: 0;
2456
- border-radius: 5px;
2472
+ border-radius: 4px;
2457
2473
  background: transparent;
2458
- color: #aba59d;
2474
+ color: #aaa298;
2459
2475
  text-align: left;
2460
2476
  font: inherit;
2461
2477
  font-size: 10px;
2462
2478
  cursor: pointer;
2463
2479
  -webkit-app-region: no-drag;
2480
+ transition: color 0.15s;
2464
2481
  }
2465
- .mini-source:hover {
2466
- background: #ffffff08;
2482
+ .mini-source[data-provider="codex"] {
2483
+ --src: #b9c8b0;
2467
2484
  }
2468
- .mini-source[aria-pressed="true"] > span:first-child {
2469
- color: #eee4d9;
2485
+ .mini-logo {
2486
+ grid-column: 1;
2487
+ grid-row: 1;
2488
+ width: 12px;
2489
+ height: 12px;
2490
+ fill: currentColor;
2470
2491
  }
2471
- /* the selected service gets the tab's tinted plate; the other steps back */
2472
- .mini-source[aria-pressed="false"] {
2473
- opacity: 0.5;
2492
+ .mini-source-name {
2493
+ grid-column: 2;
2494
+ grid-row: 1;
2474
2495
  }
2475
- .mini-source[aria-pressed="false"]:hover {
2476
- opacity: 0.8;
2496
+ /* Selection uses contrast; urgent readings retain their warning color. */
2497
+ .mini-source[aria-pressed="false"] .mini-track i {
2498
+ opacity: 0.55;
2477
2499
  }
2478
- .mini-source[data-provider="claude"][aria-pressed="true"] {
2479
- background: #d977571c;
2500
+ .mini-source[aria-pressed="false"]:hover .mini-track i,
2501
+ .mini-source.urgent .mini-track i {
2502
+ opacity: 1;
2480
2503
  }
2481
- .mini-source[data-provider="codex"][aria-pressed="true"] {
2482
- background: #a7baa11c;
2504
+ .mini-source[aria-pressed="true"] {
2505
+ color: #eee4d9;
2506
+ }
2507
+ .mini-source[aria-pressed="true"] .mini-logo {
2508
+ color: var(--src);
2509
+ }
2510
+ .mini-source:hover {
2511
+ color: #fff4e8;
2512
+ }
2513
+ .mini-source:hover .mini-logo {
2514
+ color: var(--src);
2483
2515
  }
2484
2516
  .mini-source b {
2485
- grid-row: 2;
2486
- font-size: 18px;
2517
+ grid-column: 3;
2518
+ grid-row: 1;
2519
+ font-size: 15px;
2487
2520
  line-height: 1.1;
2488
2521
  letter-spacing: -0.4px;
2489
2522
  font-weight: 500;
2490
- color: #eee4d9;
2523
+ color: inherit;
2491
2524
  font-variant-numeric: tabular-nums;
2492
2525
  }
2493
2526
  .mini-source.urgent b {
2494
2527
  color: #f3a38b;
2495
2528
  }
2496
- .mini-source.urgent > span:first-child::after {
2529
+ .mini-source.urgent .mini-source-name::after {
2497
2530
  content: " !";
2498
2531
  color: #f3a38b;
2499
2532
  }
2500
2533
  .mini-track {
2501
- grid-row: 3;
2534
+ grid-column: 2 / -1;
2535
+ grid-row: 2;
2502
2536
  height: 2px;
2503
2537
  border-radius: 2px;
2504
2538
  overflow: hidden;
2505
2539
  background: #ffffff0d;
2506
2540
  }
2541
+ body.collapsed.dual #mini-reset {
2542
+ text-align: right;
2543
+ margin-top: 7px;
2544
+ font-size: 8px;
2545
+ color: #b4a18a;
2546
+ }
2547
+
2507
2548
  .mini-track i {
2508
2549
  display: block;
2509
2550
  height: 100%;
@@ -2578,11 +2619,19 @@ body.view-codex .mrow .mname::before {
2578
2619
  body.view-codex .ac-caret {
2579
2620
  display: none;
2580
2621
  }
2622
+ /* keyboard focus: a soft inner glow in the brand coral rather than a detached
2623
+ cream ring — still visible, but it reads as the control lighting up */
2581
2624
  #card button:focus-visible,
2582
2625
  #card input:focus-visible,
2583
2626
  #account-chip:focus-visible {
2584
- outline: 2px solid #c9bda6;
2585
- outline-offset: 2px;
2627
+ outline: none;
2628
+ box-shadow:
2629
+ inset 0 0 0 1px #e6a07f8c,
2630
+ 0 0 0 3px #d977571f;
2631
+ }
2632
+ #controls button:focus-visible {
2633
+ background: rgba(255, 255, 255, 0.1);
2634
+ color: #fff;
2586
2635
  }
2587
2636
  @media (prefers-reduced-motion: reduce) {
2588
2637
  #card,
@@ -2790,3 +2839,184 @@ body.awaiting #acc-intro.connection-card {
2790
2839
  .claude-connect-btn:hover {
2791
2840
  background: #ce926d33;
2792
2841
  }
2842
+
2843
+ /* ---- voice: speech bubble + mute ---- */
2844
+ /* the bubble speaks the sprite's language: square pixels, a notched 2px
2845
+ outline (no radius), a stepped tail aimed at the pet's head, small type */
2846
+ #bubble {
2847
+ --ink: #221b16;
2848
+ --paper: #f1e9dd;
2849
+ position: absolute;
2850
+ left: 84px;
2851
+ top: 16px;
2852
+ z-index: 4;
2853
+ max-width: 148px;
2854
+ padding: 5px 7px 6px;
2855
+ background: var(--paper);
2856
+ color: var(--ink);
2857
+ font:
2858
+ 600 9px / 1.35 ui-monospace,
2859
+ "SF Mono",
2860
+ Menlo,
2861
+ monospace;
2862
+ letter-spacing: -0.01em;
2863
+ /* four offset edges instead of a border: the corners stay open, like a sprite */
2864
+ box-shadow:
2865
+ 0 -2px 0 0 var(--ink),
2866
+ 0 2px 0 0 var(--ink),
2867
+ -2px 0 0 0 var(--ink),
2868
+ 2px 0 0 0 var(--ink),
2869
+ 2px 4px 0 0 #0006;
2870
+ cursor: pointer;
2871
+ -webkit-app-region: no-drag;
2872
+ transform-origin: 0% 100%;
2873
+ animation: bubble-pop 0.18s steps(3, end);
2874
+ }
2875
+ #bubble[hidden] {
2876
+ display: none;
2877
+ }
2878
+ /* the tail: a stepped right triangle hanging off the bottom-left edge, aimed
2879
+ down at the pet. The ink one is the outline; the paper one sits 2px inside it
2880
+ and starts on the outline row, so the bubble opens into the tail */
2881
+ #bubble::before,
2882
+ #bubble::after {
2883
+ content: "";
2884
+ position: absolute;
2885
+ top: 100%;
2886
+ }
2887
+ #bubble::before {
2888
+ left: 8px;
2889
+ width: 14px;
2890
+ height: 12px;
2891
+ background: var(--ink);
2892
+ clip-path: polygon(
2893
+ 0 0,
2894
+ 14px 0,
2895
+ 14px 2px,
2896
+ 12px 2px,
2897
+ 12px 4px,
2898
+ 10px 4px,
2899
+ 10px 6px,
2900
+ 8px 6px,
2901
+ 8px 8px,
2902
+ 6px 8px,
2903
+ 6px 10px,
2904
+ 4px 10px,
2905
+ 4px 12px,
2906
+ 0 12px
2907
+ );
2908
+ }
2909
+ #bubble::after {
2910
+ left: 10px;
2911
+ width: 10px;
2912
+ height: 10px;
2913
+ background: var(--paper);
2914
+ clip-path: polygon(
2915
+ 0 0,
2916
+ 10px 0,
2917
+ 10px 2px,
2918
+ 8px 2px,
2919
+ 8px 4px,
2920
+ 6px 4px,
2921
+ 6px 6px,
2922
+ 4px 6px,
2923
+ 4px 8px,
2924
+ 2px 8px,
2925
+ 2px 10px,
2926
+ 0 10px
2927
+ );
2928
+ }
2929
+ #bubble.leaving {
2930
+ animation: bubble-out 0.15s steps(2, end) forwards;
2931
+ }
2932
+ @keyframes bubble-pop {
2933
+ from {
2934
+ transform: scale(0.5);
2935
+ opacity: 0;
2936
+ }
2937
+ to {
2938
+ transform: scale(1);
2939
+ opacity: 1;
2940
+ }
2941
+ }
2942
+ @keyframes bubble-out {
2943
+ to {
2944
+ transform: scale(0.8);
2945
+ opacity: 0;
2946
+ }
2947
+ }
2948
+ body.settings-open #bubble {
2949
+ display: none;
2950
+ }
2951
+ /* collapsed: above the pet, in the card's flow, tail pointing straight down */
2952
+ body.collapsed #bubble {
2953
+ position: relative;
2954
+ left: auto;
2955
+ top: auto;
2956
+ max-width: none;
2957
+ margin: 6px 2px 14px;
2958
+ grid-column: 1 / -1;
2959
+ transform-origin: 50% 100%;
2960
+ }
2961
+ /* one service: the card has no title band, so clear the window controls, and
2962
+ hug the short line instead of spanning the whole card */
2963
+ body.collapsed:not(.dual) #bubble {
2964
+ align-self: center;
2965
+ margin-top: 26px;
2966
+ text-align: center;
2967
+ }
2968
+ body.collapsed.dual #bubble {
2969
+ justify-self: start;
2970
+ }
2971
+ body.collapsed #bubble::before {
2972
+ left: calc(50% - 7px);
2973
+ }
2974
+ body.collapsed #bubble::after {
2975
+ left: calc(50% - 5px);
2976
+ }
2977
+ /* with both services the pet sits in the left column: aim there */
2978
+ body.collapsed.dual #bubble::before {
2979
+ left: 24px;
2980
+ }
2981
+ body.collapsed.dual #bubble::after {
2982
+ left: 26px;
2983
+ }
2984
+
2985
+ #controls button[hidden] {
2986
+ display: none;
2987
+ }
2988
+ /* a fifth button pushes #controls ~23px left: the account chip gives that up.
2989
+ Minimized is always silent, so the button (and the squeeze) go away there */
2990
+ body.has-mute #account-chip {
2991
+ max-width: 141px;
2992
+ }
2993
+ body.collapsed #mute {
2994
+ display: none;
2995
+ }
2996
+ #mute .ic-muted,
2997
+ #mute.muted .ic-sound {
2998
+ display: none;
2999
+ }
3000
+ #mute.muted .ic-muted {
3001
+ display: block;
3002
+ }
3003
+ #mute.muted {
3004
+ color: var(--coral-soft);
3005
+ }
3006
+
3007
+ .set-row.voice-sub .set-row-label {
3008
+ font-weight: 400;
3009
+ font-size: 10.5px;
3010
+ color: var(--muted);
3011
+ }
3012
+ #set-rows.talk-off .voice-sub {
3013
+ opacity: 0.35;
3014
+ pointer-events: none;
3015
+ }
3016
+
3017
+ @media (prefers-reduced-motion: reduce) {
3018
+ #bubble,
3019
+ #bubble.leaving {
3020
+ animation: none;
3021
+ }
3022
+ }
@@ -0,0 +1,243 @@
1
+ // ---- the pet's voice ----
2
+ // Two channels. The bubble carries the meaning: a short remark, only on a
3
+ // transition, and only when there's something specific to say — a pet that
4
+ // talks on a timer is Clippy. The sound carries the personality: chiptune
5
+ // blips, one square-wave note per letter, Animal Crossing style. Oscillators
6
+ // only, so no assets, no dependency, and no language baked into the sound.
7
+ ;(() => {
8
+ const BASE_HZ = 400
9
+ const SPREAD_HZ = 250
10
+ const STEP_MS = 115
11
+ const MAX_BLIPS = 14 // a sentence is a babble, not a recital
12
+ const GAIN = 0.05
13
+
14
+ // the tone doubles as state: hot is faster and higher, sleepy slower and lower
15
+ const MOODS = {
16
+ normal: { speed: 1, pitch: 1 },
17
+ fire: { speed: 0.72, pitch: 1.25 },
18
+ sleepy: { speed: 1.35, pitch: 0.8 },
19
+ }
20
+
21
+ // letters and digits each get a fixed pitch, so the same word always sounds
22
+ // the same; anything else is a rest
23
+ function pitchOf(ch) {
24
+ const c = ch.toLowerCase()
25
+ if (c >= 'a' && c <= 'z') return BASE_HZ + ((c.charCodeAt(0) - 97) / 25) * SPREAD_HZ
26
+ if (c >= '0' && c <= '9') return BASE_HZ + ((c.charCodeAt(0) - 48) / 9) * SPREAD_HZ
27
+ return null
28
+ }
29
+
30
+ // the schedule for a line: [{ at, hz, dur }] in ms, pure so it can be tested
31
+ function blipPlan(text, mood = 'normal') {
32
+ const m = MOODS[mood] || MOODS.normal
33
+ const step = STEP_MS * m.speed
34
+ const plan = []
35
+ let t = 0
36
+ for (const ch of String(text)) {
37
+ if (plan.length >= MAX_BLIPS) break
38
+ const hz = pitchOf(ch)
39
+ if (hz != null) {
40
+ plan.push({ at: Math.round(t), hz: Math.round(hz * m.pitch), dur: Math.round(step * 0.6) })
41
+ t += step
42
+ } else if (/[.,!?—:;]/.test(ch)) {
43
+ t += step * 1.5 // a breath on punctuation
44
+ } else if (ch === ' ') {
45
+ t += step * 0.4
46
+ }
47
+ }
48
+ return plan
49
+ }
50
+
51
+ // `makeCtx` builds the AudioContext lazily: creating one up front would hold
52
+ // an audio device open for a pet that, by default, never makes a sound
53
+ function createBlipper(makeCtx) {
54
+ let ctx = null
55
+ return {
56
+ play(text, mood) {
57
+ try {
58
+ ctx = ctx || makeCtx()
59
+ if (!ctx) return 0
60
+ if (ctx.state === 'suspended') ctx.resume?.()
61
+ const plan = blipPlan(text, mood)
62
+ const t0 = ctx.currentTime + 0.02
63
+ for (const b of plan) {
64
+ const start = t0 + b.at / 1000
65
+ const end = start + b.dur / 1000
66
+ const osc = ctx.createOscillator()
67
+ const gain = ctx.createGain()
68
+ osc.type = 'square'
69
+ osc.frequency.setValueAtTime(b.hz, start)
70
+ // a quick attack and decay, or every note clicks
71
+ gain.gain.setValueAtTime(0, start)
72
+ gain.gain.linearRampToValueAtTime(GAIN, start + 0.008)
73
+ gain.gain.exponentialRampToValueAtTime(0.0001, end)
74
+ osc.connect(gain)
75
+ gain.connect(ctx.destination)
76
+ osc.start(start)
77
+ osc.stop(end + 0.01)
78
+ }
79
+ return plan.length
80
+ } catch {
81
+ return 0 // no audio device, or a context the OS took away: stay quiet
82
+ }
83
+ },
84
+ }
85
+ }
86
+
87
+ // ---- remarks ----
88
+ // Each returns { text, short }, or null when there's nothing worth saying.
89
+ // `text` is a full, spoken sentence for the scene; `short` is the glance the
90
+ // mini face has room for. Numbers arrive preformatted through `fmt` / `fmtDur`
91
+ // so this file stays free of the widget.
92
+ const line = (text, short) => ({ text, short: short || text })
93
+
94
+ // the small hours get their own hello: "Good morning!" at 2am rings false
95
+ const LATE_UNTIL = 5
96
+ function salutation(hour) {
97
+ if (hour < LATE_UNTIL) return 'Still up?'
98
+ return hour < 12 ? 'Good morning!' : hour < 18 ? 'Good afternoon!' : 'Good evening!'
99
+ }
100
+ function hello(hour) {
101
+ if (hour < LATE_UNTIL) return 'Up late!'
102
+ return hour < 12 ? 'Morning!' : hour < 18 ? 'Afternoon!' : 'Evening!'
103
+ }
104
+
105
+ // `days` is the 30-day series, oldest first, today last
106
+ function greetingLine(days, hour, fmt) {
107
+ if (!Array.isArray(days) || days.length < 2) return null
108
+ const y = days[days.length - 2]
109
+ if (!y) return null
110
+ const week = days.slice(-8, -1) // the seven days before today, yesterday included
111
+ const worked = week.filter((v) => v > 0)
112
+ const hi = salutation(hour)
113
+ const short = `${hello(hour)} ${fmt(y)} yesterday.`
114
+ if (worked.length > 1 && y >= Math.max(...week))
115
+ return line(
116
+ `${hi} Yesterday was your heaviest day this week, ${fmt(y)} tokens.`,
117
+ `${hello(hour)} Big day yesterday.`,
118
+ )
119
+ const avg = worked.reduce((n, v) => n + v, 0) / (worked.length || 1)
120
+ if (worked.length > 2 && y < avg * 0.5)
121
+ return line(`${hi} Yesterday was a quiet one, just ${fmt(y)} tokens.`, short)
122
+ return line(`${hi} You spent ${fmt(y)} tokens yesterday.`, short)
123
+ }
124
+
125
+ function fireLine(pct, etaMs, resetMs, fmtDur) {
126
+ const p = Math.round(pct)
127
+ if (etaMs != null)
128
+ return line(
129
+ `Getting warm, ${p}% already. At this pace you'll run out in about ${fmtDur(etaMs)}.`,
130
+ `${p}%, getting warm!`,
131
+ )
132
+ if (resetMs != null)
133
+ return line(
134
+ `Getting warm, ${p}% already. It resets in ${fmtDur(resetMs)}.`,
135
+ `${p}%, getting warm!`,
136
+ )
137
+ return line(`Getting warm, ${p}% already.`, `${p}%, getting warm!`)
138
+ }
139
+
140
+ function resetLine(was) {
141
+ return line(`Fresh window! The last one closed at ${Math.round(was)}%.`, 'Fresh window!')
142
+ }
143
+
144
+ // what each activity was, said the way you'd say it
145
+ const DOING = {
146
+ editing: 'mostly editing code',
147
+ reading: 'mostly reading',
148
+ planning: 'mostly planning',
149
+ running: 'mostly running commands',
150
+ researching: 'mostly researching',
151
+ delegating: 'mostly delegating to agents',
152
+ waiting: 'mostly waiting on you',
153
+ }
154
+
155
+ // the recap of the window that just closed (#31), built from whatever is
156
+ // known, so a pet launched mid-session still says something true
157
+ function recapLine({ was, tokens, activeMs, top }, fmt, fmtDur) {
158
+ if (!(tokens > 0)) return resetLine(was ?? 0)
159
+ let text = `Fresh window! That was ${fmt(tokens)} tokens`
160
+ if (activeMs >= 60000) text += ` over ${fmtDur(activeMs)}`
161
+ if (DOING[top]) text += `, ${DOING[top]}`
162
+ return line(`${text}.`, `Fresh window! ${fmt(tokens)} last time.`)
163
+ }
164
+
165
+ // Codex speaks through the same pet, so its lines always say whose they are
166
+ function codexFireLine(pct, resetMs, fmtDur) {
167
+ const p = Math.round(pct)
168
+ const text =
169
+ resetMs != null
170
+ ? `Codex is at ${p}% now. It resets in ${fmtDur(resetMs)}.`
171
+ : `Codex is at ${p}% now.`
172
+ return line(text, `Codex at ${p}%!`)
173
+ }
174
+ function codexMaxedLine(resetAt) {
175
+ return line(
176
+ resetAt ? `Codex is maxed out. It's back at ${resetAt}.` : 'Codex is maxed out.',
177
+ 'Codex is maxed out.',
178
+ )
179
+ }
180
+ function codexResetLine(was) {
181
+ return line(
182
+ `Codex has a fresh window! The last one closed at ${Math.round(was)}%.`,
183
+ 'Codex: fresh window!',
184
+ )
185
+ }
186
+
187
+ function maxedLine(resetAt) {
188
+ return resetAt
189
+ ? line(`That's the limit. I'll be back at ${resetAt}.`, `Maxed out till ${resetAt}.`)
190
+ : line("That's the limit. Nap time.", 'Maxed out.')
191
+ }
192
+
193
+ function welcomeLine(awayMs, pct, fmtDur) {
194
+ const away = fmtDur(awayMs)
195
+ const text =
196
+ pct != null
197
+ ? `Welcome back! You were gone ${away}. The session's at ${Math.round(pct)}%.`
198
+ : `Welcome back! You were gone ${away}.`
199
+ return line(text, 'Welcome back!')
200
+ }
201
+
202
+ function streakLine(ms, fmtDur) {
203
+ return line(`You've been at it for ${fmtDur(ms)} straight. Stretch break?`, 'Stretch break?')
204
+ }
205
+
206
+ // a record only counts against a real history, and past a floor that makes
207
+ // it more than noise
208
+ const RECORD_FLOOR = 1e6
209
+ function recordLine(days, fmt) {
210
+ if (!Array.isArray(days) || days.length < 8) return null
211
+ const today = days[days.length - 1]
212
+ const before = days.slice(0, -1)
213
+ const best = Math.max(...before)
214
+ if (best <= 0 || today < RECORD_FLOOR || today <= best) return null
215
+ return line(
216
+ `New record! ${fmt(today)} tokens today, your biggest day in a month.`,
217
+ `New record: ${fmt(today)}!`,
218
+ )
219
+ }
220
+
221
+ const api = {
222
+ BASE_HZ,
223
+ SPREAD_HZ,
224
+ STEP_MS,
225
+ MAX_BLIPS,
226
+ pitchOf,
227
+ blipPlan,
228
+ createBlipper,
229
+ greetingLine,
230
+ fireLine,
231
+ resetLine,
232
+ recapLine,
233
+ codexFireLine,
234
+ codexMaxedLine,
235
+ codexResetLine,
236
+ maxedLine,
237
+ welcomeLine,
238
+ streakLine,
239
+ recordLine,
240
+ }
241
+ if (typeof module === 'object' && module.exports) module.exports = api
242
+ else globalThis.Voice = api
243
+ })()