clauddy 1.24.0 → 1.26.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.
@@ -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
+ })()