@henryz2004/agency 1.0.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.
Files changed (41) hide show
  1. package/README.md +106 -0
  2. package/lib/codex.js +211 -0
  3. package/lib/control.js +168 -0
  4. package/lib/live.js +493 -0
  5. package/lib/opencode.js +447 -0
  6. package/lib/paths.js +12 -0
  7. package/lib/roster.js +204 -0
  8. package/lib/transcript.js +361 -0
  9. package/lib/usage.js +346 -0
  10. package/package.json +27 -0
  11. package/public/app.js +1021 -0
  12. package/public/audio-controls.js +165 -0
  13. package/public/avatar.js +467 -0
  14. package/public/characters/dev-auburn.json +32 -0
  15. package/public/characters/dev-auburn.png +0 -0
  16. package/public/characters/dev-beanie.json +32 -0
  17. package/public/characters/dev-beanie.png +0 -0
  18. package/public/characters/dev-glasses.json +32 -0
  19. package/public/characters/dev-glasses.png +0 -0
  20. package/public/chat-panel.css +514 -0
  21. package/public/chat-panel.js +815 -0
  22. package/public/index.html +190 -0
  23. package/public/lab.html +129 -0
  24. package/public/leaderboard.js +222 -0
  25. package/public/metric.js +34 -0
  26. package/public/mock-agents.js +70 -0
  27. package/public/mock.js +277 -0
  28. package/public/music/Console_Morning.mp3 +0 -0
  29. package/public/music/Midnight_Desk.mp3 +0 -0
  30. package/public/music/The_Plant_Beside_the_Door.mp3 +0 -0
  31. package/public/music/Three_AM_Window.mp3 +0 -0
  32. package/public/office.js +1484 -0
  33. package/public/sound.js +382 -0
  34. package/public/sprites.js +983 -0
  35. package/public/style.css +506 -0
  36. package/public/ui.js +50 -0
  37. package/scripts/_pixpng.mjs +104 -0
  38. package/scripts/animsheet.mjs +60 -0
  39. package/scripts/charsheet.mjs +61 -0
  40. package/scripts/install-hook.mjs +120 -0
  41. package/server.js +370 -0
@@ -0,0 +1,382 @@
1
+ // sound.js — two INDEPENDENT audio channels for the office:
2
+ // 1. SFX — synthesized "busy office" keyboard clatter via the Web Audio API.
3
+ // Zero asset files: each keystroke is a short filtered noise burst. The
4
+ // clatter rate/intensity scales with how many agents are generating;
5
+ // fully silent when nobody is working.
6
+ // 2. MUSIC — a shuffled, endless lo-fi playlist (real mp3s under public/music/),
7
+ // played with a plain HTMLAudioElement (no AudioContext).
8
+ //
9
+ // The two channels enable/disable, persist, and start/stop ENTIRELY separately:
10
+ // music-on/SFX-off and SFX-on/music-off are both valid. Each has its own toggle
11
+ // function and its own localStorage key.
12
+ //
13
+ // BROWSER RULES: the AudioContext is created/resumed ONLY after a user gesture
14
+ // (a toggle). Music play() is likewise only kicked off from a gesture. Nothing
15
+ // autoplays; everything fails soft; prefs persist in localStorage and default OFF.
16
+
17
+ // New per-channel pref keys. We migrate the OLD combined key (`agency.sound`)
18
+ // forward: if a returning user had the single combined toggle ON, both channels
19
+ // default ON so they aren't surprised by the split.
20
+ const SFX_KEY = 'agency.sfx';
21
+ const MUSIC_KEY = 'agency.music';
22
+ const LEGACY_KEY = 'agency.sound';
23
+
24
+ // --- SFX (Web Audio keyboard clatter) ---------------------------------------
25
+
26
+ let ctx = null; // AudioContext, created lazily on first SFX enable
27
+ let master = null; // master gain -> destination
28
+ let noiseBuffer = null; // shared white-noise buffer for key clicks
29
+
30
+ let sfxEnabled = false; // whether the clatter engine is actively running
31
+ let workingCount = 0; // agents currently generating
32
+ let scheduler = null; // setInterval handle for the clatter loop
33
+ let nextKeyAt = 0; // ctx.currentTime of the next scheduled keystroke
34
+
35
+ // Keep the master volume LOW and tasteful.
36
+ const MASTER_VOL = 0.18;
37
+
38
+ // --- ambient music (real lo-fi tracks) --------------------------------------
39
+ // Four mastered lo-fi tracks under public/music/, played as a shuffled, endless
40
+ // playlist whenever MUSIC is on. Uses a plain HTMLAudioElement (NOT Web Audio) so
41
+ // it needs no AudioContext and fails soft on its own; the volume is ramped by hand
42
+ // for a gentle fade in/out. Started/stopped from the music toggle — a user gesture,
43
+ // so the browser autoplay policy is satisfied.
44
+ const MUSIC_VOL = 0.5; // comfortable background level for the recordings
45
+ const MUSIC_FADE_MS = 1800; // fade-in (a quicker fade-out on stop) so it never jars
46
+ const TRACKS = [
47
+ '/music/Midnight_Desk.mp3',
48
+ '/music/The_Plant_Beside_the_Door.mp3',
49
+ '/music/Three_AM_Window.mp3',
50
+ '/music/Console_Morning.mp3',
51
+ ];
52
+ let musicEnabled = false; // whether the playlist channel is on
53
+ let musicEl = null; // current HTMLAudioElement, or null when off
54
+ let musicQueue = []; // shuffled play order (indices into TRACKS)
55
+ let musicPos = 0; // position within musicQueue
56
+ let musicMisses = 0; // consecutive load failures → give up if all unreachable
57
+ let currentTrack = ''; // pretty name of the track currently sounding (or '')
58
+
59
+ // Track-change subscribers. We BOTH dispatch a window CustomEvent and call any
60
+ // registered callbacks, so consumers can pick whichever is simpler.
61
+ const trackListeners = new Set();
62
+ const TRACK_EVENT = 'agency:track';
63
+
64
+ // --- pref read/write (per channel, fail-soft) -------------------------------
65
+
66
+ function readKey(key) {
67
+ try {
68
+ return localStorage.getItem(key);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ function writeKey(key, on) {
74
+ try {
75
+ localStorage.setItem(key, on ? 'on' : 'off');
76
+ } catch {
77
+ /* ignore */
78
+ }
79
+ }
80
+
81
+ // Resolve the persisted pref for a channel, applying the one-time legacy
82
+ // migration: if the new key was never written but the old combined key was ON,
83
+ // treat this channel as ON.
84
+ function loadChannelPref(key) {
85
+ const v = readKey(key);
86
+ if (v === 'on') return true;
87
+ if (v === 'off') return false;
88
+ // No new-key value yet → fall back to the legacy combined pref.
89
+ return readKey(LEGACY_KEY) === 'on';
90
+ }
91
+
92
+ // --- SFX engine -------------------------------------------------------------
93
+
94
+ // Build a small white-noise buffer once; reused for every keystroke.
95
+ function makeNoiseBuffer(ac) {
96
+ const len = Math.floor(ac.sampleRate * 0.12); // ~120ms is plenty per click
97
+ const buf = ac.createBuffer(1, len, ac.sampleRate);
98
+ const data = buf.getChannelData(0);
99
+ for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
100
+ return buf;
101
+ }
102
+
103
+ // Create the audio graph. Must run inside a user gesture.
104
+ function ensureContext() {
105
+ if (ctx) return;
106
+ const AC = window.AudioContext || window.webkitAudioContext;
107
+ if (!AC) return;
108
+ ctx = new AC();
109
+ noiseBuffer = makeNoiseBuffer(ctx);
110
+ master = ctx.createGain();
111
+ master.gain.value = MASTER_VOL;
112
+ // A gentle low-pass over the whole bus keeps it soft, not hissy.
113
+ const lp = ctx.createBiquadFilter();
114
+ lp.type = 'lowpass';
115
+ lp.frequency.value = 5200;
116
+ lp.Q.value = 0.3;
117
+ master.connect(lp);
118
+ lp.connect(ctx.destination);
119
+ }
120
+
121
+ // One synthesized key click: a short filtered noise burst with a fast decay.
122
+ function playKey(time, gain) {
123
+ if (!ctx || !noiseBuffer) return;
124
+ const src = ctx.createBufferSource();
125
+ src.buffer = noiseBuffer;
126
+
127
+ // Band-pass-ish shaping so each click sounds like a key, not static.
128
+ const bp = ctx.createBiquadFilter();
129
+ bp.type = 'bandpass';
130
+ bp.frequency.value = 1400 + Math.random() * 2200; // vary pitch per key
131
+ bp.Q.value = 0.7 + Math.random() * 0.8;
132
+
133
+ const env = ctx.createGain();
134
+ const peak = gain * (0.6 + Math.random() * 0.5);
135
+ const dur = 0.018 + Math.random() * 0.03; // 18–48ms clicks
136
+ env.gain.setValueAtTime(0.0001, time);
137
+ env.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), time + 0.002);
138
+ env.gain.exponentialRampToValueAtTime(0.0001, time + dur);
139
+
140
+ src.connect(bp);
141
+ bp.connect(env);
142
+ env.connect(master);
143
+ src.start(time);
144
+ src.stop(time + dur + 0.01);
145
+ }
146
+
147
+ // Schedule keystrokes a little ahead of the clock for smooth timing. Called on
148
+ // an interval; it fills the lookahead window with clicks whose density tracks
149
+ // the working-agent count.
150
+ const LOOKAHEAD = 0.18; // seconds to schedule ahead
151
+ function tickScheduler() {
152
+ if (!ctx || !sfxEnabled || workingCount <= 0) return;
153
+ const now = ctx.currentTime;
154
+ if (nextKeyAt < now) nextKeyAt = now;
155
+
156
+ // Keys/sec scales with working agents: a brisk-but-not-frantic typist each.
157
+ // ~7 keys/sec per working agent, with a soft cap so a big team isn't a roar.
158
+ const kps = Math.min(38, 6.5 * workingCount + 1.5);
159
+ // Slightly lower per-key gain as the floor gets busier (overlap sums up).
160
+ const perKeyGain = Math.max(0.35, 1 / (1 + workingCount * 0.45));
161
+
162
+ while (nextKeyAt < now + LOOKAHEAD) {
163
+ // Humanize: exponential-ish gaps around the mean interval.
164
+ const meanGap = 1 / kps;
165
+ const gap = meanGap * (0.45 + Math.random() * 1.3);
166
+ playKey(nextKeyAt, perKeyGain);
167
+ nextKeyAt += gap;
168
+ }
169
+ }
170
+
171
+ function startScheduler() {
172
+ if (scheduler != null) return;
173
+ scheduler = setInterval(tickScheduler, 60);
174
+ }
175
+ function stopScheduler() {
176
+ if (scheduler != null) {
177
+ clearInterval(scheduler);
178
+ scheduler = null;
179
+ }
180
+ }
181
+
182
+ // ---- shuffled-playlist player ----------------------------------------------
183
+
184
+ // Fisher–Yates shuffle of a copy (Math.random is fine in the browser).
185
+ function shuffled(arr) {
186
+ const a = arr.slice();
187
+ for (let i = a.length - 1; i > 0; i--) {
188
+ const j = Math.floor(Math.random() * (i + 1));
189
+ [a[i], a[j]] = [a[j], a[i]];
190
+ }
191
+ return a;
192
+ }
193
+
194
+ // Turn '/music/The_Plant_Beside_the_Door.mp3' → 'The Plant Beside the Door'.
195
+ function prettyTrack(path) {
196
+ try {
197
+ const file = path.split('/').pop() || path;
198
+ return file.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim();
199
+ } catch {
200
+ return '';
201
+ }
202
+ }
203
+
204
+ // Record the now-playing track name and notify subscribers (event + callbacks).
205
+ function setCurrentTrack(name) {
206
+ currentTrack = name || '';
207
+ try {
208
+ window.dispatchEvent(new CustomEvent(TRACK_EVENT, { detail: { track: currentTrack } }));
209
+ } catch {
210
+ /* ignore — no window or CustomEvent */
211
+ }
212
+ trackListeners.forEach((fn) => {
213
+ try { fn(currentTrack); } catch { /* a bad listener must not break playback */ }
214
+ });
215
+ }
216
+
217
+ // Linearly ramp el.volume to `target` over `ms`, then run `done`. Tracks its own
218
+ // interval on el._fadeId so a teardown can cancel an in-flight fade.
219
+ function ramp(el, target, ms, done) {
220
+ const start = el.volume;
221
+ const t0 = Date.now();
222
+ const id = setInterval(() => {
223
+ const k = ms <= 0 ? 1 : Math.min(1, (Date.now() - t0) / ms);
224
+ el.volume = Math.max(0, Math.min(1, start + (target - start) * k));
225
+ if (k >= 1) {
226
+ clearInterval(id);
227
+ if (el._fadeId === id) el._fadeId = 0;
228
+ if (done) done();
229
+ }
230
+ }, 40);
231
+ el._fadeId = id;
232
+ }
233
+
234
+ // Pause + release an element, cancelling any fade still running on it.
235
+ function killEl(el) {
236
+ if (!el) return;
237
+ if (el._fadeId) { clearInterval(el._fadeId); el._fadeId = 0; }
238
+ try { el.pause(); el.removeAttribute('src'); el.load(); } catch { /* fail-soft */ }
239
+ }
240
+
241
+ // Start the playlist: shuffle, then play the first track. Needs no AudioContext;
242
+ // called from toggleMusic (a user gesture) so play() is allowed. Fail-soft.
243
+ function startMusic() {
244
+ if (musicEl) return;
245
+ try {
246
+ musicQueue = shuffled(TRACKS.map((_, i) => i));
247
+ musicPos = 0;
248
+ musicMisses = 0;
249
+ playCurrent();
250
+ } catch {
251
+ musicEl = null; // fail-soft
252
+ setCurrentTrack('');
253
+ }
254
+ }
255
+
256
+ // Load + fade in the current queue track; auto-advance on end, skip on error.
257
+ function playCurrent() {
258
+ const path = TRACKS[musicQueue[musicPos]];
259
+ const el = new Audio(path);
260
+ el.preload = 'auto';
261
+ el.volume = 0.0001;
262
+ el._fadeId = 0;
263
+ musicEl = el;
264
+ // Announce the name as soon as it actually starts sounding (and reset misses).
265
+ el.addEventListener('playing', () => {
266
+ if (musicEl !== el) return;
267
+ musicMisses = 0;
268
+ setCurrentTrack(prettyTrack(path));
269
+ });
270
+ el.addEventListener('ended', () => { if (musicEnabled && musicEl === el) advanceTrack(); });
271
+ el.addEventListener('error', () => {
272
+ if (!musicEnabled || musicEl !== el) return;
273
+ musicMisses++;
274
+ if (musicMisses >= TRACKS.length) { stopMusic(); return; } // all unreachable → give up
275
+ advanceTrack();
276
+ });
277
+ const p = el.play();
278
+ if (p && p.catch) p.catch(() => { /* autoplay blocked / decode fail — fail-soft */ });
279
+ ramp(el, MUSIC_VOL, MUSIC_FADE_MS); // gentle fade-in
280
+ }
281
+
282
+ // Move to the next track, reshuffling each time the set completes so the order
283
+ // varies. Tears down the finished element first.
284
+ function advanceTrack() {
285
+ const old = musicEl;
286
+ musicEl = null;
287
+ killEl(old);
288
+ musicPos++;
289
+ if (musicPos >= musicQueue.length) {
290
+ musicQueue = shuffled(TRACKS.map((_, i) => i));
291
+ musicPos = 0;
292
+ }
293
+ playCurrent();
294
+ }
295
+
296
+ // Fade out + release the current track. Detaches musicEl immediately so a quick
297
+ // re-enable builds a fresh element without the old one's fade fighting it (the
298
+ // fade-out runs on the detached element's own _fadeId and pauses it when done).
299
+ function stopMusic() {
300
+ const el = musicEl;
301
+ setCurrentTrack(''); // clear the now-playing label immediately
302
+ if (!el) return;
303
+ musicEl = null;
304
+ if (el._fadeId) { clearInterval(el._fadeId); el._fadeId = 0; }
305
+ ramp(el, 0.0001, 400, () => killEl(el));
306
+ }
307
+
308
+ // Suspend the AudioContext to save CPU when SFX is off — deferred so the clatter
309
+ // tail finishes, and guarded so a quick re-enable doesn't get muted.
310
+ function maybeSuspendContext() {
311
+ setTimeout(() => {
312
+ if (!sfxEnabled && ctx && ctx.state === 'running') ctx.suspend();
313
+ }, 450);
314
+ }
315
+
316
+ // --- public API ------------------------------------------------------------
317
+
318
+ export function isSfxEnabled() {
319
+ return sfxEnabled;
320
+ }
321
+ export function isMusicEnabled() {
322
+ return musicEnabled;
323
+ }
324
+
325
+ // Pretty name of the track currently sounding, or '' when music is off.
326
+ export function currentTrackName() {
327
+ return currentTrack;
328
+ }
329
+
330
+ // Subscribe to track changes. Returns an unsubscribe function. The callback is
331
+ // invoked with the pretty track name (or '' when music stops). Consumers may
332
+ // instead listen for the `agency:track` CustomEvent on window — both fire.
333
+ export function onTrackChange(fn) {
334
+ if (typeof fn !== 'function') return () => {};
335
+ trackListeners.add(fn);
336
+ return () => trackListeners.delete(fn);
337
+ }
338
+
339
+ // Read the persisted prefs WITHOUT creating any audio or starting anything
340
+ // (safe pre-gesture). Returns the desired pre-gesture button state for both
341
+ // channels; the engines stay off until the first user gesture toggles them.
342
+ // The actual start happens in toggleSfx/toggleMusic.
343
+ export function initSoundPref() {
344
+ return {
345
+ sfx: loadChannelPref(SFX_KEY),
346
+ music: loadChannelPref(MUSIC_KEY),
347
+ };
348
+ }
349
+
350
+ // Toggle the SFX (keyboard clatter) channel. MUST be called from a user gesture
351
+ // handler. Creates/resumes the AudioContext on first enable. Returns new state.
352
+ export function toggleSfx() {
353
+ sfxEnabled = !sfxEnabled;
354
+ writeKey(SFX_KEY, sfxEnabled);
355
+ if (sfxEnabled) {
356
+ ensureContext();
357
+ if (ctx && ctx.state === 'suspended') ctx.resume();
358
+ startScheduler();
359
+ } else {
360
+ stopScheduler();
361
+ maybeSuspendContext();
362
+ }
363
+ return sfxEnabled;
364
+ }
365
+
366
+ // Toggle the MUSIC (lo-fi playlist) channel. MUST be called from a user gesture
367
+ // handler so play() is allowed. Returns the new enabled state.
368
+ export function toggleMusic() {
369
+ musicEnabled = !musicEnabled;
370
+ writeKey(MUSIC_KEY, musicEnabled);
371
+ if (musicEnabled) {
372
+ startMusic();
373
+ } else {
374
+ stopMusic();
375
+ }
376
+ return musicEnabled;
377
+ }
378
+
379
+ // Called each poll/frame with the current count of working agents.
380
+ export function updateSound(count) {
381
+ workingCount = Math.max(0, count | 0);
382
+ }