@double_geste/agent-standup 1.22.0 → 1.23.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.
@@ -3,7 +3,7 @@
3
3
  // agent-standup [serve] [--port 4747] [--open] start the local server and (optionally) open the page
4
4
  // agent-standup status [--here] [--json] counts of working / needs you / your turn
5
5
  // agent-standup statusline same, one short line; reads Claude Code's statusLine JSON on stdin
6
- // Everything reads the local session files Claude Code already writes. Nothing is sent anywhere.
6
+ // Everything reads the local session files Claude Code already writes. Nothing is sent anywhere unless you turn on the optional French translation or jokes (they use your own Claude login or ANTHROPIC_API_KEY).
7
7
  const path = require('path'), { spawn } = require('child_process');
8
8
  const pkg = require('../package.json');
9
9
  const argv = process.argv.slice(2), flags = {}, rest = [];
@@ -26,7 +26,7 @@ Options
26
26
  --open open the page in your browser
27
27
  --projects DIR where the session files are (default ~/.claude/projects, or $CLAUDE_CONFIG_DIR/projects)
28
28
  --characters DIR folder with your own pixel-art characters (default ~/.agent-standup/characters)
29
- --here status: only sessions of the current folder
29
+ --here status: only sessions of the current folder (matched on its real path, as Claude Code names it)
30
30
  --json status: machine-readable output (add --titles to include session titles)
31
31
  `;
32
32
  if (flags.help || cmd === 'help') { process.stdout.write(HELP); process.exit(0); }
@@ -34,7 +34,10 @@ if (flags.version || cmd === 'version') { console.log(pkg.version); process.exit
34
34
  if (flags.projects) process.env.AGENT_STANDUP_PROJECTS = path.resolve(flags.projects);
35
35
  if (flags.config) process.env.CLAUDE_CONFIG_DIR = path.resolve(flags.config);
36
36
  if (flags.characters) process.env.AGENT_STANDUP_CHARACTERS = path.resolve(flags.characters);
37
- if (flags.port) process.env.PORT = String(parseInt(flags.port, 10) || 4747);
37
+ if (flags.port !== undefined) { // a real port number or nothing: no silent fallback to another port
38
+ if (!/^\d+$/.test(String(flags.port)) || +flags.port < 1 || +flags.port > 65535) { console.error(`Invalid --port "${flags.port}": use a number between 1 and 65535.`); process.exit(2); }
39
+ process.env.PORT = String(+flags.port);
40
+ }
38
41
  const server = require('../server.js');
39
42
  const projKey = (dir) => String(dir || '').replace(/[^a-zA-Z0-9]/g, '-'); // Claude Code's project folder naming
40
43
 
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ // Kokoro worker: loads the Kokoro-82M model once, then reads one JSON request per stdin line ({ id, text, voice, speed, out }),
3
+ // writes a 16-bit WAV to `out` and answers { id, ok, seconds } or { id, ok: false, error }. Started by kokoro.js.
4
+ const fs = require('fs'), path = require('path'), readline = require('readline'), { createRequire } = require('module');
5
+ const MODEL = 'onnx-community/Kokoro-82M-v1.0-ONNX';
6
+ const send = (o) => fs.writeSync(1, JSON.stringify(o) + '\n');
7
+ function wav(samples, rate) { // Float32 in [-1, 1] → 16-bit mono WAV
8
+ const n = samples.length, buf = Buffer.alloc(44 + n * 2);
9
+ buf.write('RIFF', 0); buf.writeUInt32LE(36 + n * 2, 4); buf.write('WAVEfmt ', 8); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(1, 22); buf.writeUInt32LE(rate, 24); buf.writeUInt32LE(rate * 2, 28); buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34); buf.write('data', 36); buf.writeUInt32LE(n * 2, 40);
10
+ for (let i = 0; i < n; i++) { const s = Math.max(-1, Math.min(1, samples[i])); buf.writeInt16LE(Math.round(s < 0 ? s * 32768 : s * 32767), 44 + i * 2); }
11
+ return buf;
12
+ }
13
+ async function main() {
14
+ const dir = process.env.AGENT_STANDUP_KOKORO_DIR, dtype = process.env.AGENT_STANDUP_KOKORO_DTYPE || 'q4f16';
15
+ const req = createRequire(path.join(dir, 'node_modules', 'x.js')); // resolve from the install folder, not from the extension
16
+ const tf = req('@huggingface/transformers'); tf.env.cacheDir = path.join(dir, 'models');
17
+ const { KokoroTTS } = req('kokoro-js'); const tts = await KokoroTTS.from_pretrained(MODEL, { dtype, device: 'cpu' });
18
+ send({ ready: true });
19
+ for await (const line of readline.createInterface({ input: process.stdin })) {
20
+ let m; try { m = JSON.parse(line); } catch { continue; }
21
+ try { const a = await tts.generate(String(m.text), { voice: m.voice, speed: +m.speed || 1 }); fs.writeFileSync(m.out, wav(a.audio, a.sampling_rate)); send({ id: m.id, ok: true, seconds: a.audio.length / a.sampling_rate }); }
22
+ catch (e) { send({ id: m.id, ok: false, error: String((e && e.message) || e).slice(0, 200) }); }
23
+ }
24
+ }
25
+ main().catch((e) => { console.error(String((e && e.stack) || e)); process.exit(1); });
package/kokoro.js ADDED
@@ -0,0 +1,82 @@
1
+ // Natural voices with Kokoro-82M (kokoro-js on ONNX Runtime), installed on demand outside the extension.
2
+ // Nothing here runs until the person clicks Install in ⚙ → Sound: the packages (about 430 MB) go to one user folder, the model (about 155 MB) is fetched on first use.
3
+ const fs = require('fs'), os = require('os'), path = require('path'), { spawn } = require('child_process');
4
+
5
+ const WIN = process.platform === 'win32';
6
+ const DIR = process.env.AGENT_STANDUP_KOKORO_DIR || (WIN ? path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'agent-standup', 'kokoro') : path.join(os.homedir(), '.cache', 'agent-standup', 'kokoro'));
7
+ const PKG = 'kokoro-js@1.2.1', DTYPE = 'q4f16', SIZE_MB = 580, MAX_RESTARTS = 3;
8
+ const WORKER = process.env.AGENT_STANDUP_KOKORO_WORKER || path.join(__dirname, 'kokoro-worker.js'), LOG = path.join(DIR, 'install.log');
9
+
10
+ // In the order agents get them (the lead takes the first), so successive agents sound distinct. French has a single voice in Kokoro.
11
+ const VOICES = [['af_heart', 'Heart', 'en_US'], ['am_fenrir', 'Fenrir', 'en_US'], ['bf_emma', 'Emma', 'en_GB'], ['am_michael', 'Michael', 'en_US'], ['af_bella', 'Bella', 'en_US'], ['bm_george', 'George', 'en_GB'], ['af_nicole', 'Nicole', 'en_US'], ['am_puck', 'Puck', 'en_US'], ['ff_siwis', 'Siwis', 'fr_FR']].map(([name, label, locale]) => ({ name, label, locale }));
12
+ const voices = () => VOICES, hasVoice = (n) => VOICES.some((v) => v.name === n);
13
+ const speedFor = (wpm) => Math.max(0.5, Math.min(2, Math.round((wpm / 178) * 20) / 20)); // 178 words a minute is about Kokoro's natural pace
14
+ const exists = (f) => { try { fs.statSync(f); return true; } catch { return false; } };
15
+ const installed = () => exists(path.join(DIR, 'node_modules', 'kokoro-js', 'package.json'));
16
+
17
+ function findNpm() {
18
+ const home = os.homedir(), names = WIN ? ['npm.cmd', 'npm.bat', 'npm'] : ['npm'], cands = [];
19
+ for (const d of (process.env.PATH || '').split(path.delimiter)) for (const n of names) cands.push(path.join(d, n));
20
+ const dirs = WIN ? [path.join(process.env.APPDATA || '', 'npm'), path.join(process.env.ProgramFiles || 'C:\\Program Files', 'nodejs')] : ['/opt/homebrew/bin', '/usr/local/bin', path.join(home, '.local/bin'), path.join(home, '.npm-global/bin'), path.join(home, '.volta/bin')];
21
+ try { const nvm = path.join(home, '.nvm', 'versions', 'node'); for (const v of fs.readdirSync(nvm)) dirs.push(path.join(nvm, v, 'bin')); } catch {}
22
+ for (const d of dirs) for (const n of names) cands.push(path.join(d, n));
23
+ return cands.find((c) => c && exists(c)) || null;
24
+ }
25
+
26
+ // ---- install: `npm install --prefix DIR kokoro-js@x`, output in DIR/install.log, one job at a time
27
+ let job = null, lastError = '';
28
+ function install() {
29
+ if (job || installed()) return status();
30
+ const npm = findNpm(); if (!npm) { lastError = 'npm not found'; return status(); }
31
+ fs.mkdirSync(DIR, { recursive: true }); lastError = '';
32
+ const out = fs.openSync(LOG, 'w'), args = ['install', '--prefix', DIR, '--no-audit', '--no-fund', PKG];
33
+ job = WIN && /\.(cmd|bat)$/i.test(npm) ? spawn('"' + npm + '"', args.map((a) => (a === DIR ? '"' + a + '"' : a)), { shell: true, stdio: ['ignore', out, out], windowsHide: true }) : spawn(npm, args, { stdio: ['ignore', out, out], windowsHide: true });
34
+ const done = (why) => { job = null; try { fs.closeSync(out); } catch {} if (why) lastError = why; else warm(); };
35
+ job.on('error', (e) => done('npm: ' + e.message));
36
+ job.on('close', (code) => done(code === 0 && installed() ? '' : 'npm install failed (exit ' + code + ')'));
37
+ return status();
38
+ }
39
+
40
+ // ---- worker: one child process that holds the model, started on first use, restarted after a crash up to MAX_RESTARTS times; one line at a time
41
+ let worker = null, restarts = 0, seq = 0; const queue = []; let busy = false;
42
+ function startWorker() {
43
+ if (worker) return worker;
44
+ if (restarts >= MAX_RESTARTS) throw new Error('kokoro worker gave up');
45
+ const p = spawn(process.execPath, [WORKER], { env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', AGENT_STANDUP_KOKORO_DIR: DIR, AGENT_STANDUP_KOKORO_DTYPE: DTYPE }, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
46
+ const w = (worker = { p, ready: false, pending: new Map(), err: '' }); let buf = '';
47
+ p.stdout.on('data', (d) => {
48
+ buf += d; let i;
49
+ while ((i = buf.indexOf('\n')) >= 0) {
50
+ const line = buf.slice(0, i); buf = buf.slice(i + 1); let m; try { m = JSON.parse(line); } catch { continue; } // library chatter is ignored
51
+ if (m.ready) { w.ready = true; lastError = ''; continue; }
52
+ const r = w.pending.get(m.id); if (!r) continue; w.pending.delete(m.id); m.ok ? r.resolve(m) : r.reject(new Error(m.error || 'tts failed'));
53
+ }
54
+ });
55
+ p.stderr.on('data', (d) => { w.err = (w.err + d).slice(-600); }); p.stdin.on('error', () => {});
56
+ const gone = (why) => { if (worker !== w) return; worker = null; restarts++; lastError = why; const jobs = [...w.pending.values()]; w.pending.clear(); busy = false; for (const r of jobs) r.reject(new Error(why)); };
57
+ p.on('error', (e) => gone('kokoro worker: ' + e.message)); p.on('close', (code) => gone('kokoro worker exited (' + code + ') ' + (w.err.trim().split('\n').pop() || '')));
58
+ return w;
59
+ }
60
+ function warm() { if (installed() && !worker && restarts < MAX_RESTARTS) try { startWorker(); } catch {} }
61
+ function pump() {
62
+ if (busy || !queue.length) return;
63
+ const j = queue.shift(); let w; try { w = startWorker(); } catch (e) { j.reject(e); return pump(); }
64
+ const id = ++seq, next = (fn) => (v) => { busy = false; fn(v); pump(); }; busy = true;
65
+ w.pending.set(id, { resolve: next(j.resolve), reject: next(j.reject) });
66
+ w.p.stdin.write(JSON.stringify({ id, text: j.text, voice: j.voice, speed: j.speed, out: j.out }) + '\n');
67
+ }
68
+ function synth(text, voice, speed, out) {
69
+ if (!installed()) return Promise.reject(new Error('kokoro not installed'));
70
+ if (!hasVoice(voice)) return Promise.reject(new Error('unknown voice'));
71
+ return new Promise((resolve, reject) => { queue.push({ text: String(text).slice(0, 2500), voice, speed, out, resolve, reject }); pump(); });
72
+ }
73
+ function status() {
74
+ let log = ''; try { log = fs.readFileSync(LOG, 'utf8').slice(-400); } catch {}
75
+ return { dir: DIR, sizeMb: SIZE_MB, installed: installed(), installing: !!job, ready: !!(worker && worker.ready), gaveUp: restarts >= MAX_RESTARTS, error: lastError || null, npm: !!findNpm(), log };
76
+ }
77
+ function shutdown() {
78
+ for (const j of queue.splice(0)) j.reject(new Error('kokoro shut down'));
79
+ if (worker) { const w = worker; worker = null; try { w.p.kill(); } catch {} for (const r of w.pending.values()) r.reject(new Error('kokoro shut down')); w.pending.clear(); }
80
+ busy = false; if (job) { try { job.kill(); } catch {} job = null; }
81
+ }
82
+ module.exports = { DIR, voices, hasVoice, speedFor, status, install, warm, synth, shutdown };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@double_geste/agent-standup",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "description": "Your Claude Code agents as a live video call, from the terminal: a local page in your browser, plus one-line status for status bars. Free, open source, zero token cost.",
5
5
  "bin": {
6
6
  "agent-standup": "bin/agent-standup.js"
@@ -9,6 +9,8 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "server.js",
12
+ "kokoro.js",
13
+ "kokoro-worker.js",
12
14
  "demo.js",
13
15
  "public",
14
16
  "media",
package/public/index.html CHANGED
@@ -237,11 +237,14 @@
237
237
  <h4 data-fr="Son" data-en="Sound">Son</h4>
238
238
  <button class="mi" data-t="voice"><span data-fr="Voix des agents" data-en="Agent voices">Voix des agents</span><i class="sw"></i></button>
239
239
  <button class="mi" data-t="office"><span data-fr="Ambiance de bureau (clavier, café…)" data-en="Office ambience (keys, coffee…)">Ambiance de bureau (clavier, café…)</span><i class="sw"></i></button>
240
+ <button class="mi" data-t="meme"><span data-fr="🔥 Mode mème (n’importe quoi)" data-en="🔥 Meme mode (unhinged)">🔥 Mode mème (n’importe quoi)</span><i class="sw"></i></button>
241
+ <div class="box" id="memeBox" style="display:none"><div class="lbl"><span data-fr="Intensité" data-en="Intensity">Intensité</span> <span id="memeLvlVal"></span></div><input id="memeLvl" type="range" min="1" max="11" step="1" style="width:100%" aria-label="Meme intensity"><div class="lbl"><span data-fr="Volume des effets" data-en="Effects volume">Volume des effets</span></div><input id="memeVol" type="range" min="0.1" max="1" step="0.05" style="width:100%" aria-label="Meme volume"></div>
240
242
  <div class="box" id="officeBox" style="display:none"><input id="officeVol" type="range" min="0.1" max="1" step="0.05" style="width:100%" aria-label="Ambience volume"></div>
241
243
  <button class="mi" data-t="notify"><span data-fr="Me prévenir quand les agents ont fini" data-en="Notify me when agents finish">Me prévenir quand les agents ont fini</span><i class="sw"></i></button>
242
244
  <button class="mi" data-t="music"><span data-fr="Musique d’ambiance (radio FIP)" data-en="Office music (FIP radio)">Musique d’ambiance (radio FIP)</span><i class="sw"></i></button>
243
245
  <div class="box" id="musicBox" style="display:none"><select id="station"></select><input id="musicVol" type="range" min="0" max="1" step="0.05" style="width:100%" aria-label="Music volume"></div>
244
246
  <div class="box" id="vstyleBox"><select id="vstyle"></select></div>
247
+ <div class="box" id="kokoroBox" style="display:none"><button class="btn" id="kokoroInstall" style="width:100%"></button><div class="hint" id="kokoroHint" style="padding:2px 0 6px"></div></div>
245
248
  <button class="mi" data-t="roster"><span data-fr="Liste des agents (à gauche)" data-en="Agents list (left)">Liste des agents (à gauche)</span><i class="sw"></i></button>
246
249
  <button class="mi" data-t="reactions"><span data-fr="Barre de réactions (👍 👏 ☕)" data-en="Reactions bar (👍 👏 ☕)">Barre de réactions (👍 👏 ☕)</span><i class="sw"></i></button>
247
250
  <button class="mi" data-t="banter"><span data-fr="Petites répliques et animations" data-en="Little lines & animations">Petites répliques et animations</span><i class="sw"></i></button>
@@ -282,6 +285,9 @@
282
285
  </footer>
283
286
  <div id="card"></div><div id="recap"><div class="box"></div></div>
284
287
 
288
+ <link rel="stylesheet" href="/meme.css">
289
+ <script src="/meme.js"></script>
290
+ <script src="/api/prefs.js"></script>
285
291
  <script>
286
292
  // =====================================================================
287
293
  // helpers, prefs, i18n
@@ -297,10 +303,12 @@ const EMBED = Q.get('embed') === '1' || window.parent !== window;
297
303
  if (Q.get('pin')) S.pin = Q.get('pin');
298
304
 
299
305
  let prefs = { lang: 'en', voice: true, roster: true, banter: true, reactions: false, office: false, officeVol: 0.8, vstyle: 'cartoon', notify: false, epi: false, vanish: true, music: false, station: 'fip', musicVol: 0.35, view: 'call' };
300
- try { Object.assign(prefs, JSON.parse(localStorage.getItem('cc.prefs') || '{}')); } catch {}
306
+ { let loc = {}; try { loc = JSON.parse(localStorage.getItem('cc.prefs') || '{}') || {}; } catch {} // the settings file (shared by every window) and this origin's copy: the more recent one wins
307
+ const sv = window.SAVED_PREFS && typeof window.SAVED_PREFS === 'object' ? window.SAVED_PREFS : {}; Object.assign(prefs, (sv._t || 0) > (loc._t || 0) ? sv : loc); }
301
308
  if (prefs.vstyle === 'babble') prefs.vstyle = 'animal', prefs.vstyleChosen = true;
302
309
  if (!prefs.vstyleChosen && (prefs.vstyle === 'animal' || prefs.vstyle === 'sims')) prefs.vstyle = 'cartoon'; // the gibberish styles are opt-in: understandable voices by default
303
- const savePrefs = () => { try { localStorage.setItem('cc.prefs', JSON.stringify(prefs)); } catch {} };
310
+ let prefsTimer = null;
311
+ const savePrefs = () => { prefs._t = Date.now(); try { localStorage.setItem('cc.prefs', JSON.stringify(prefs)); } catch {} clearTimeout(prefsTimer); prefsTimer = setTimeout(() => { fetch('/api/prefs', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(prefs) }).catch(() => {}); }, 400); };
304
312
  // Languages: French and English live in the code as pairs L(french, english); every other language is a catalogue public/locales/<code>.json mapping the English text to its translation.
305
313
  // A missing string falls back to English, so a half-translated language still works. See public/locales/README.md to add one.
306
314
  const LANGS = [['en', 'English', 'en'], ['fr', 'Français', 'fr'], ['zh-TW', '繁體中文', 'zh-Hant-TW'], ['zh-CN', '简体中文', 'zh-Hans-CN'], ['es', 'Español', 'es'], ['de', 'Deutsch', 'de'], ['pt-BR', 'Português (Brasil)', 'pt-BR'], ['ja', '日本語', 'ja']];
@@ -339,7 +347,7 @@ async function loadChars() {
339
347
  } catch {}
340
348
  CHARS = next;
341
349
  }
342
- const customFor = (id) => (id === 'main' ? CHARS.lead : id === 'user' ? CHARS.you : CHARS.pool.length ? CHARS.pool[hash(id) % CHARS.pool.length] : null);
350
+ const customFor = (id) => (id === 'main' ? CHARS.lead : id === 'user' ? CHARS.you : CHARS.pool.length ? CHARS.pool.reduce((b, c) => (hash(id + '|' + (c.idle || '')) > hash(id + '|' + (b.idle || '')) ? c : b)) : null);
343
351
  function renderCharInfo() {
344
352
  const el = $('#charInfo'); if (!el) return;
345
353
  el.innerHTML = CHARS.n ? `✓ ${CHARS.n} ${esc(L('personnage(s) personnalisé(s) chargé(s)', 'custom character(s) loaded'))}` : `${esc(L('Déposez vos images (PNG) dans ce dossier pour remplacer les personnages :', 'Drop your pixel art (PNG) in this folder to replace the characters:'))}<br><code style="word-break:break-all">${esc(CHARS.dir)}</code><br>${esc(L('lead.png = le chef · you.png = vous · les autres = les sous-agents · nom-talk.png = bouche ouverte', 'lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth'))}`;
@@ -439,7 +447,7 @@ function avatarSVG(p, o, uid = '') { // uid keeps SVG ids unique per instance:
439
447
  const sparks = star(22, 28, 3) + star(78, 22, 2.6, '#ff9bb8') + star(70, 40, 1.9, '#9be7ff') + star(30, 46, 1.6, '#ffe58a');
440
448
  const custFig = (c, talking) => { const base = 'x="20" y="6" width="60" height="58" preserveAspectRatio="xMidYMax meet"', px = 'image-rendering:pixelated;';
441
449
  return `<image class="cimg" href="${c.idle}" ${base} style="${px}${talking && c.talk ? 'display:none' : ''}"/>` + (c.talk ? `<image class="cimg cimg-talk" href="${c.talk}" ${base} style="${px}${talking ? '' : 'display:none'}"/>` : ''); };
442
- const fig = p.custom ? custFig(p.custom, st && o.m > 0) : `${arm('l')}${bodyShape(p.shape, `url(#${gid})`, c2)}${arm('r')}${face}${acc}`;
450
+ const fig = p.custom ? custFig(p.custom, st && o.m === 2) : `${arm('l')}${bodyShape(p.shape, `url(#${gid})`, c2)}${arm('r')}${face}${acc}`;
443
451
  return `<svg viewBox="0 0 100 75" preserveAspectRatio="xMidYMid slice"${st ? ' xmlns="http://www.w3.org/2000/svg" width="400" height="300"' : ''}>
444
452
  <defs><linearGradient id="${gid}" gradientUnits="userSpaceOnUse" x1="0" y1="12" x2="0" y2="72"><stop offset="0" stop-color="${c1}"/><stop offset="1" stop-color="${c}"/></linearGradient>
445
453
  <linearGradient id="${uid}dk${p.scene}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${sc.desk[0]}"/><stop offset="1" stop-color="${sc.desk[1]}"/></linearGradient>
@@ -534,7 +542,7 @@ const AUD = {
534
542
  const src = c.createBufferSource(); src.buffer = buf; src.loop = true; const lp = c.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 500;
535
543
  const g = c.createGain(); g.gain.value = 0.3; src.connect(lp).connect(g).connect(this.sfx); src.start(); this.apply(); OFFICE.start();
536
544
  },
537
- apply() { if (!this.ctx) return; this.sfx.gain.value = prefs.office && !this.off() ? 1 : 0; this.voice.gain.value = prefs.voice && !this.off() ? 1 : 0; this.applyMusic(); RADIO.sync(); },
545
+ apply() { if (!this.ctx) return; this.sfx.gain.value = prefs.office && !this.off() ? 1 : 0; this.voice.gain.value = prefs.voice && !this.off() ? 1 : 0; this.applyMusic(); RADIO.sync(); if (typeof MEME !== 'undefined') MEME.apply(); },
538
546
  // Music fades down quickly when an agent starts talking and comes back slowly once the room is quiet.
539
547
  applyMusic() { if (!this.ctx) return; this.amb.gain.setTargetAtTime((this.ducked ? 0.35 : 1) * (prefs.officeVol ?? 0.8), this.ctx.currentTime, 0.25); const v = prefs.music && !this.off() ? (prefs.musicVol ?? 0.35) * (this.ducked ? 0.16 : 1) : 0; this.music.gain.setTargetAtTime(v, this.ctx.currentTime, this.ducked ? 0.12 : 0.7); },
540
548
  blip(f) { if (!this.ctx || this.off()) return; const c = this.ctx, t = c.currentTime, o = c.createOscillator(), g = c.createGain(); o.type = 'sine'; o.frequency.setValueAtTime(f, t); o.frequency.exponentialRampToValueAtTime(f * 1.5, t + 0.1); g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.12, t + 0.015); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.22); o.connect(g).connect(c.destination); o.start(t); o.stop(t + 0.25); },
@@ -652,17 +660,23 @@ function rankVoices(list) { // list of names → best distinct voices first
652
660
  return out.slice(0, 5);
653
661
  }
654
662
  const TTS = {
655
- server: false, platform: '', pool: { fr: [], en: [] }, cache: new Map(),
663
+ server: false, engine: 'system', platform: '', pool: { fr: [], en: [] }, cache: new Map(),
656
664
  async init() {
657
- try { const d = await (await fetch('/api/voices')).json(); this.platform = d.platform || ''; if (!d.available) return; this.server = true;
665
+ this.engine = 'system'; this.server = false; this.pool = { fr: [], en: [] }; this.cache.clear();
666
+ try {
667
+ if (prefs.vstyle === 'kokoro') { // natural voices installed on demand; until they are, the system voices stay in use
668
+ const k = await (await fetch('/api/voices?engine=kokoro')).json();
669
+ if (k.available && k.ready) { this.server = true; this.engine = 'kokoro'; this.platform = k.platform || ''; for (const l of ['fr', 'en']) this.pool[l] = k.voices.filter((v) => v.locale.startsWith(l + '_')).map((v) => v.name); return; }
670
+ }
671
+ const d = await (await fetch('/api/voices')).json(); this.platform = d.platform || ''; if (!d.available) return; this.server = true;
658
672
  for (const l of ['fr', 'en']) this.pool[l] = rankVoices(d.voices.filter((v) => v.locale.startsWith(l + '_')).map((v) => v.name)); } catch {}
659
673
  },
660
674
  voiceFor(p, fr = FR()) { const pool = this.pool[fr ? 'fr' : 'en']; return pool.length ? (p.id === 'main' ? pool[0] : pool[(p.vi >>> 2) % pool.length]) : null; },
661
675
  rate() { return Math.round(Math.max(120, Math.min(330, 178 * (S.live ? 1.12 : +$('#speed').value))) / 10) * 10; },
662
676
  buf(text, voice, rate) {
663
- const key = voice + '|' + rate + '|' + text;
677
+ const key = this.engine + '|' + voice + '|' + rate + '|' + text;
664
678
  if (!this.cache.has(key)) {
665
- this.cache.set(key, fetch(`/api/tts?voice=${encodeURIComponent(voice)}&rate=${rate}&text=${encodeURIComponent(text)}`).then((r) => { if (!r.ok) throw new Error('tts'); return r.arrayBuffer(); }).then((b) => AUD.ctx.decodeAudioData(b)));
679
+ this.cache.set(key, fetch(`/api/tts?engine=${this.engine}&voice=${encodeURIComponent(voice)}&rate=${rate}&text=${encodeURIComponent(text)}`).then((r) => { if (!r.ok) throw new Error('tts'); return r.arrayBuffer(); }).then((b) => AUD.ctx.decodeAudioData(b)));
666
680
  if (this.cache.size > 60) this.cache.delete(this.cache.keys().next().value);
667
681
  this.cache.get(key).catch(() => this.cache.delete(key));
668
682
  }
@@ -745,12 +759,12 @@ async function speakServer(text, p, gain, token, fr = FR()) {
745
759
  const buf = await TTS.buf(speakable(text), voice, TTS.rate()); if (token !== S.token) return;
746
760
  const c = AUD.ctx, src = c.createBufferSource(); src.buffer = buf;
747
761
  const an = c.createAnalyser(); an.fftSize = 256; const g = c.createGain(); g.gain.value = gain; let stops = [];
748
- if (prefs.vstyle === 'natural') src.connect(an); else { const ch = fxChain(c, src, fxFor(p)); stops = ch.stops; ch.node.connect(an); }
762
+ if (prefs.vstyle === 'natural' || prefs.vstyle === 'kokoro') src.connect(an); else { const ch = fxChain(c, src, fxFor(p)); stops = ch.stops; ch.node.connect(an); }
749
763
  an.connect(g); g.connect(AUD.voice);
750
764
  const data = new Uint8Array(an.fftSize);
751
765
  S.level[p.id] = () => { an.getByteTimeDomainData(data); let s = 0; for (const v of data) { const x = (v - 128) / 128; s += x * x; } return Math.sqrt(s / data.length); }; // drives the mouth
752
766
  AUD.live.add(src);
753
- await new Promise((res) => { src.onended = res; setTimeout(res, buf.duration * 1000 + 800); src.start(); });
767
+ captionRun(p.id, text, buf.duration * 1000); await new Promise((res) => { src.onended = res; setTimeout(res, buf.duration * 1000 + 800); src.start(); });
754
768
  AUD.live.delete(src); delete S.level[p.id]; stops.forEach((o) => { try { o.stop(); } catch {} });
755
769
  }
756
770
  function speakBrowser(text, p, token, fr = FR()) { // fallback where no server voice exists (Windows/Linux)
@@ -770,7 +784,32 @@ function speakBrowser(text, p, token, fr = FR()) { // fallback where no server
770
784
  next();
771
785
  });
772
786
  }
787
+ // Subtitles: a long line is shown one piece (sentence, or clause of a long sentence) at a time, the piece being said. The split is by characters, spread over the audio's real length when known.
788
+ function pieces(text) {
789
+ const out = [];
790
+ for (const sen of String(text).split(/(?<=[.!?…]["')\]]*)\s+/)) {
791
+ const t = sen.trim(); if (!t) continue;
792
+ if (t.length <= 110) { out.push(t); continue; }
793
+ let cur = '';
794
+ for (const part of t.split(/(?<=[,;:—])\s+/)) { if (cur && cur.length + part.length > 90) { out.push(cur); cur = part; } else cur = cur ? cur + ' ' + part : part; }
795
+ if (cur) out.push(cur);
796
+ }
797
+ return out;
798
+ }
799
+ const capTimers = new Map(); // agent id → timers that advance its subtitle
800
+ function captionClear(id) { (capTimers.get(id) || []).forEach(clearTimeout); capTimers.delete(id); }
801
+ function setCaption(id, text) {
802
+ const t = $('#t-' + id), b = t && t.querySelector('.bubble'), say = S.say[id]; if (!b || !say) return;
803
+ b.innerHTML = (say.label ? `<small>${esc(say.label)}</small>` : '') + esc(text); if (S.present === id) $('#pcap').textContent = text;
804
+ }
805
+ function captionRun(id, text, totalMs) {
806
+ captionClear(id); const ps = pieces(text); if (ps.length < 2 || !(totalMs > 0)) return;
807
+ const n = ps.reduce((a, x) => a + x.length, 0), timers = []; let at = 0; setCaption(id, ps[0]); at += totalMs * ps[0].length / n;
808
+ for (let i = 1; i < ps.length; i++) { const x = ps[i], when = at; timers.push(setTimeout(() => setCaption(id, x), when)); at += totalMs * x.length / n; }
809
+ capTimers.set(id, timers);
810
+ }
773
811
  async function speakLine(text, p, primary, token, fr = FR()) {
812
+ captionRun(p.id, text, estMs(text));
774
813
  if (AUD.off() || !prefs.voice) { if (!AUD.off() && prefs.office) AUD.murmur(0.5 + (p.vi % 5) / 10, estMs(text) / 1000); return sleep(estMs(text)); }
775
814
  if (S.hurry) return sleep(estMs(text));
776
815
  if ((prefs.vstyle === 'animal' || prefs.vstyle === 'sims') && AUD.ctx) { try { await speakBabble(text, p, primary ? 1 : 0.5, token); return; } catch {} }
@@ -834,7 +873,7 @@ function paintStatus() {
834
873
  $('#seek').value = S.idx; $('#pos').textContent = `${Math.min(S.idx + 1, S.events.length)} / ${S.events.length}`;
835
874
  }
836
875
  const applyStatus = (ev) => {
837
- if (ev.kind === 'order') { (S.waitOn[ev.agent] = S.waitOn[ev.agent] || new Set()).add(ev.to); S.orderTs[ev.to] = ev.ts || 0; S.status[ev.to] = 'working'; delete S.doneAt[ev.to]; S.gone.delete(ev.to); S.leaving.delete(ev.to); const t = $('#t-' + ev.to); if (t) t.classList.remove('vanishing'); }
876
+ if (ev.kind === 'order') { (S.waitOn[ev.agent] = S.waitOn[ev.agent] || new Set()).add(ev.to); S.orderTs[ev.to] = ev.ts || 0; S.status[ev.to] = 'working'; delete S.doneAt[ev.to]; S.gone.delete(ev.to); S.leaving.delete(ev.to); if (!ev.bg && ev.toolId) S.run[ev.agent] = { id: ev.toolId, ts: ev.ts, start: performance.now(), name: 'Agent' }; const t = $('#t-' + ev.to); if (t) t.classList.remove('vanishing'); }
838
877
  if (ev.kind === 'finish') { if (S.waitOn[ev.to]) S.waitOn[ev.to].delete(ev.agent); if (S.status[ev.agent] !== 'done') { S.status[ev.agent] = 'done'; S.doneAt[ev.agent] = S.clock; } delete S.run[ev.agent]; const lr = S.run[ev.to]; if (lr && /^(Task|Agent)$/.test(lr.name) && !(S.waitOn[ev.to] && S.waitOn[ev.to].size)) delete S.run[ev.to]; }
839
878
  if (ev.kind === 'report') { if (S.waitOn[ev.to]) S.waitOn[ev.to].delete(ev.agent); S.status[ev.agent] = 'done'; S.doneAt[ev.agent] = S.clock; delete S.run[ev.agent]; }
840
879
  };
@@ -877,7 +916,7 @@ const WIN1M = /(opus-4-[78]|opus-5|sonnet-5|fable-5)/;
877
916
  function ctxWin(model, ctx) { const base = WIN1M.test(model || '') ? 1e6 : 2e5; return ctx > base ? 1e6 : base; } // the transcript does not say which window is in use: infer it, a bigger observed context proves the 1M window
878
917
  function tally(ev) { // numbers that pile up over a session: token usage, errors, last sign of life
879
918
  if (ev.kind === 'compact') { S.compacts[ev.agent] = (S.compacts[ev.agent] || 0) + 1; delete S.ctxWarned[ev.agent]; }
880
- const det = loopStep(S.rep, ev); if (det) { S.loops[ev.agent] = det; later('badges', renderBadges, 300); } else if (S.loops[ev.agent] && (ev.kind === 'results' ? ev.items.every((it) => !it.err) : (ev.kind === 'tools' && S.rep[ev.agent].n < 2))) { delete S.loops[ev.agent]; later('badges', renderBadges, 300); }
919
+ const det = loopStep(S.rep, ev); if (det) { S.loops[ev.agent] = det; later('badges', renderBadges, 300); } else if (S.loops[ev.agent] && (ev.kind === 'results' ? (S.loops[ev.agent].kind === 'errors' && ev.items.every((it) => !it.err)) : (ev.kind === 'tools' && S.rep[ev.agent].n < 2))) { delete S.loops[ev.agent]; later('badges', renderBadges, 300); }
881
920
  if (ev.kind === 'usage') { const c = ev.u.i + ev.u.r + ev.u.w; if (c > 0 && ev.model !== '<synthetic>') { S.ctx[ev.agent] = c; S.model[ev.agent] = ev.model; later('tok', renderCtx, 300); } const u = (S.usage[ev.agent] = S.usage[ev.agent] || { i: 0, o: 0, r: 0, w: 0 }); u.i += ev.u.i; u.o += ev.u.o; u.r += ev.u.r; u.w += ev.u.w; later('tok', renderTokens, 300); }
882
921
  else if (ev.kind === 'results') ev.items.forEach((it) => { if (it.err) { S.errs[ev.agent] = (S.errs[ev.agent] || 0) + 1; later('badges', renderBadges, 300); oops(ev.agent); } });
883
922
  else if (ev.kind === 'tools' || ev.kind === 'say') S.lastAct[ev.agent] = ev.ts;
@@ -1127,7 +1166,7 @@ function vanish(id) {
1127
1166
  S.gone.add(id); const t = $('#t-' + id), p = S.agents[id]; if (!t || !p) return;
1128
1167
  toast(`${p.name} ${L('a quitté l’appel', 'left the call')} 👋`);
1129
1168
  if (t.style.display === 'none' || document.body.classList.contains('tree')) return;
1130
- S.leaving.add(id); const say = prefs.banter && !S.hurry && !S.speakers.has(id), go = () => { t.classList.remove('bye'); if (!S.leaving.has(id)) return; t.classList.add('vanishing'); poof(t, p.shirt); AUD.tone(660, .35, 'sine', .07); AUD.tone(330, .5, 'sine', .06, .12);
1169
+ S.leaving.add(id); const say = prefs.banter && !S.hurry && !S.speakers.has(id), go = () => { t.classList.remove('bye'); if (!S.leaving.has(id)) return; t.classList.add('vanishing'); poof(t, p.shirt); MEME.fire('leave', { id }); AUD.tone(660, .35, 'sine', .07); AUD.tone(330, .5, 'sine', .06, .12);
1131
1170
  setTimeout(() => { S.leaving.delete(id); t.classList.remove('vanishing'); if (S.gone.has(id)) t.style.display = 'none'; }, 1250); };
1132
1171
  if (say) { const b = t.querySelector('.bubble'), line = byeLine(id); b.innerHTML = `<small>👋</small>${esc(line)}`; t.classList.add('bye'); t._byeT = setTimeout(go, 1500); } else go(); // a short goodbye before the poof
1133
1172
  }
@@ -1142,12 +1181,12 @@ const mouths = new Map();
1142
1181
  function startTalk(id, text, label) {
1143
1182
  const t = $('#t-' + id); if (!t) return;
1144
1183
  if (S.present && S.present !== id) hidePresent();
1145
- if (!S.speakers.size && !(S.focusLock > Date.now())) setFocus(id); S.gone.delete(id); if (S.doneAt[id] != null) S.doneAt[id] = S.clock; t.style.display = ''; t.classList.add('speaking'); S.speakers.add(id); S.say[id] = { text, label }; AUD.duck(true);
1146
- const b = t.querySelector('.bubble'); b.innerHTML = (label ? `<small>${esc(label)}</small>` : '') + esc(text);
1147
- if (S.present === id) { $('#pcap').textContent = text; $('#pcam').classList.add('speaking'); }
1184
+ if (!S.speakers.size && !(S.focusLock > Date.now())) setFocus(id); S.gone.delete(id); if (S.doneAt[id] != null) S.doneAt[id] = S.clock; t.style.display = ''; t.classList.add('speaking'); S.speakers.add(id); S.say[id] = { text, label }; AUD.duck(true); if (id === 'user' && [...S.speakers].some((x) => x !== 'user')) MEME.fire('interrupt'); MEME.fire('talk', { id, text });
1185
+ const shown0 = pieces(text), first = shown0.length > 1 ? shown0[0] : text; const b = t.querySelector('.bubble'); b.innerHTML = (label ? `<small>${esc(label)}</small>` : '') + esc(first);
1186
+ if (S.present === id) { $('#pcap').textContent = first; $('#pcam').classList.add('speaking'); }
1148
1187
  const m = t.querySelector('.mouthE'); let open = false; clearInterval(mouths.get(id));
1149
1188
  mouths.set(id, setInterval(() => {
1150
- if (!m) { const ct = t.querySelector('.cimg-talk'); if (ct) { const lv0 = S.level[id], op = lv0 ? lv0() > 0.05 : (open = !open); ct.style.display = op ? '' : 'none'; t.querySelector('.cimg').style.display = op ? 'none' : ''; } return; } // custom character: swap between its two frames
1189
+ if (!m) { const ct = t.querySelector('.cimg-talk'); if (ct) { const lv0 = S.level[id], op = lv0 ? lv0() > 0.05 : (open = !open); ct.style.display = op ? '' : 'none'; t.querySelector('.cimg').style.display = op ? 'none' : ''; if (S.present === id) { const pc = $('#pcam .cimg-talk'), pi = $('#pcam .cimg'); if (pc && pi) { pc.style.display = op ? '' : 'none'; pi.style.display = op ? 'none' : ''; } } } return; } // custom character: swap between its two frames
1151
1190
  const pm = S.present === id ? $('#pcam .mouthE') : null, lv = S.level[id]; if (lv) { const v = lv(); const ry = (.9 + Math.min(4.2, v * 28)).toFixed(1), rx = (4.8 - Math.min(1.1, v * 6)).toFixed(1); m.setAttribute('ry', ry); m.setAttribute('rx', rx); if (pm) { pm.setAttribute('ry', ry); pm.setAttribute('rx', rx); } }
1152
1191
  else { open = !open; m.setAttribute('ry', open ? 4.2 : 1.1); m.setAttribute('rx', open ? 4 : 4.7); if (pm) { pm.setAttribute('ry', open ? 4.2 : 1.1); pm.setAttribute('rx', open ? 4 : 4.7); } }
1153
1192
  }, 70));
@@ -1159,17 +1198,19 @@ setInterval(() => { // now and then a busy agent has a little thought; never w
1159
1198
  if (!prefs.banter || !S.playing || S.speakers.size || document.hidden) return; const busy = Object.keys(S.agents).filter((id) => id !== 'user' && agentState(id, Date.now()).k === 'working' && $('#t-' + id) && $('#t-' + id).style.display !== 'none');
1160
1199
  if (busy.length && Math.random() < 0.5) { const id = busy[Math.floor(Math.random() * busy.length)], tl = S.lastTool[id] || ''; thought(id, /^Bash/.test(tl) ? '⌨️' : /^(Grep|Glob|Read)/.test(tl) ? '🔍' : ['💭', '🎧', '🧠', '☕', '🎵'][Math.floor(Math.random() * 5)]); }
1161
1200
  }, 7000);
1162
- function oops(id) { if (!prefs.banter || !S.playing || S.recomputing) return; const t = $('#t-' + id); if (!t || t.style.display === 'none' || (t._oops && Date.now() - t._oops < 4000)) return; t._oops = Date.now(); t.classList.remove('oops'); void t.offsetWidth; t.classList.add('oops'); thought(id, '😬'); }
1201
+ function oops(id) { MEME.fire('fail', { id }); if (!prefs.banter || !S.playing || S.recomputing) return; const t = $('#t-' + id); if (!t || t.style.display === 'none' || (t._oops && Date.now() - t._oops < 4000)) return; t._oops = Date.now(); t.classList.remove('oops'); void t.offsetWidth; t.classList.add('oops'); thought(id, '😬'); }
1163
1202
  function confetti() {
1164
- if (!prefs.banter || document.hidden) return; const box = $('#center'), cols = ['#f5c04a', '#7c9cff', '#ff7a6b', '#5ad19a', '#c58bff'];
1203
+ MEME.fire('win'); if (!prefs.banter || document.hidden) return; const box = $('#center'), cols = ['#f5c04a', '#7c9cff', '#ff7a6b', '#5ad19a', '#c58bff'];
1165
1204
  for (let i = 0; i < 26; i++) { const c = document.createElement('i'); c.className = 'confetti'; c.style.left = Math.random() * 100 + '%'; c.style.background = cols[i % cols.length]; c.style.animationDelay = Math.random() * 0.6 + 's'; c.style.animationDuration = 1.8 + Math.random() * 1.4 + 's'; box.appendChild(c); setTimeout(() => c.remove(), 4200); }
1166
1205
  }
1167
1206
  function stopTalk(id) {
1207
+ captionClear(id);
1168
1208
  clearInterval(mouths.get(id)); mouths.delete(id); S.speakers.delete(id); if (!S.speakers.size) AUD.duck(false); if (S.focus === id && S.speakers.size) setFocus([...S.speakers][0]);
1169
1209
  if (S.present === id) { $('#pcap').textContent = ''; $('#pcam').classList.remove('speaking'); const pm = $('#pcam .mouthE'); if (pm) { pm.setAttribute('ry', .9); pm.setAttribute('rx', 4.6); } }
1170
1210
  const t = $('#t-' + id); if (!t) return;
1171
1211
  t.classList.remove('speaking'); const m = t.querySelector('.mouthE'); if (m) { m.setAttribute('ry', .9); m.setAttribute('rx', 4.6); }
1172
1212
  const ct = t.querySelector('.cimg-talk'); if (ct) { ct.style.display = 'none'; t.querySelector('.cimg').style.display = ''; }
1213
+ const pc = $('#pcam .cimg-talk'), pi = $('#pcam .cimg'); if (pc && pi && S.present === id) { pc.style.display = 'none'; pi.style.display = ''; }
1173
1214
  }
1174
1215
  const clearSpeaking = () => [...S.speakers].forEach(stopTalk);
1175
1216
 
@@ -1341,6 +1382,7 @@ async function react(ev, token) {
1341
1382
  if (!S.agents[who]) return; startTalk(who, line, ev.kind === 'order' && prefs.banter ? '💬' : '🎬'); await speakLine(line, S.agents[who], true, token); stopTalk(who);
1342
1383
  }
1343
1384
  async function card(text, sub, ms = 2500) {
1385
+ MEME.fire('card', { text });
1344
1386
  const c = $('#card'); c.innerHTML = `<div><h1>${esc(text)}</h1><p>${esc(sub || '')}</p></div>`; c.classList.add('on'); AUD.tone(392, .5, 'triangle', .12); AUD.tone(523, .6, 'triangle', .12, .25);
1345
1387
  await sleep(ms / Math.min(2, spd())); c.classList.remove('on');
1346
1388
  }
@@ -1372,7 +1414,7 @@ async function run(i) {
1372
1414
  if (ev.kind === 'usage' || ev.kind === 'compact') { if (ev.kind === 'compact' && S.live && prefs.banter) toast(`${nameOf(ev.agent)}: ${L('contexte compacté 🗜', 'context compacted 🗜')}`); i++; continue; }
1373
1415
  if (ev.kind === 'finish') { applyStatus(ev); paintStatus(); i++; continue; }
1374
1416
  if (ev.kind === 'results') { applyResults(ev, S.boost > 1); await sleep((S.demo ? 900 : S.live ? 30 : 80) / spd()); i++; continue; }
1375
- if (ev.kind === 'tools') { applyStatus(ev); tickTools(ev); AUD.click(); OFFICE.kick(ev.tools[ev.tools.length - 1].name); paintStatus(); await sleep((S.demo ? 1100 : S.live ? 110 : 250) / spd()); i++; continue; }
1417
+ if (ev.kind === 'tools') { applyStatus(ev); tickTools(ev); AUD.click(); OFFICE.kick(ev.tools[ev.tools.length - 1].name); MEME.fire('tool', { id: ev.agent }); paintStatus(); await sleep((S.demo ? 1100 : S.live ? 110 : 250) / spd()); i++; continue; }
1376
1418
  const group = [ev]; let j = i + 1; // sub-agents talking back-to-back were running in parallel: they talk over each other
1377
1419
  if (isSub(ev.agent) && ev.kind !== 'order') {
1378
1420
  while (j < Math.min(S.events.length, i + 28) && group.length < 3) {
@@ -1385,13 +1427,14 @@ async function run(i) {
1385
1427
  }
1386
1428
  }
1387
1429
  if (FR() && !S.hurry) { prefetchTr(i); await Promise.race([ensureTr(group.map((g) => baseText(g))), sleep(S.live ? 7000 : 14000)]); if (token !== S.token) return; }
1430
+ if (group.length >= 3) MEME.fire('combo', { ids: group.map((g) => g.agent) });
1388
1431
  prefetchQuips(i); warmTTS(i);
1389
1432
  const playOne = (g) => {
1390
1433
  applyStatus(g);
1391
1434
  const label = g.kind === 'order' ? `${L('pour', 'to')} ${nameOf(g.to)}` : g.kind === 'report' ? `${L('rapport à', 'report to')} ${nameOf(g.to)}` : '', line = spoken(g);
1392
1435
  startTalk(g.agent, line, label);
1393
- if (g.kind === 'order') { S.status[g.to] = 'working'; AUD.join(); setTimeout(() => { paintStatus(); fly(g.agent, g.to, '📨'); popTile(g.to); }, 30); }
1394
- if (g.kind === 'report') { AUD.ding(); setTimeout(() => { fly(g.agent, g.to, '✅'); popTile(g.agent); }, 30); }
1436
+ if (g.kind === 'order') { S.status[g.to] = 'working'; AUD.join(); MEME.fire('order', { from: g.agent, to: g.to }); setTimeout(() => { paintStatus(); fly(g.agent, g.to, '📨'); popTile(g.to); }, 30); }
1437
+ if (g.kind === 'report') { AUD.ding(); MEME.fire('report', { from: g.agent, to: g.to }); setTimeout(() => { fly(g.agent, g.to, '✅'); popTile(g.agent); }, 30); }
1395
1438
  return speakLine(clipSpeech(line, S.pace), S.agents[g.agent], true, token, FR() && trMem.has(baseText(g))).then(() => stopTalk(g.agent));
1396
1439
  };
1397
1440
  let plays; if (S.live) { plays = []; let chain = Promise.resolve(); for (const g of group) { chain = chain.then(() => (token === S.token ? playOne(g) : null)); plays.push(chain); } } else plays = group.map(playOne); // live: one voice at a time so each can be heard; replay keeps the overlapping chatter
@@ -1447,9 +1490,9 @@ async function fillProjects() { // live view: follow the latest activity anywh
1447
1490
  sel.innerHTML = `<option value="">${L('Tous les projets (le plus récent)', 'All projects (most recent)')}</option>` + rows.map((r) => `<option value="${esc(r.proj)}">${esc(niceProj(r.proj))}</option>`).join('');
1448
1491
  sel.value = prefs.proj && rows.some((r) => r.proj === prefs.proj) ? prefs.proj : ''; sel.style.display = rows.length > 1 ? '' : 'none';
1449
1492
  }
1450
- $('#liveProj').onchange = () => { prefs.proj = $('#liveProj').value; S.pin = ''; savePrefs(); S.cur = null; followCurrent(); };
1493
+ $('#liveProj').onchange = () => { Q.delete('proj'); prefs.proj = $('#liveProj').value; S.pin = ''; savePrefs(); S.cur = null; followCurrent(); };
1451
1494
  async function followCurrent() {
1452
- const cur = await (await fetch('/api/current?proj=' + encodeURIComponent(Q.get('proj') || prefs.proj || '') + (S.pin ? '&id=' + encodeURIComponent(S.pin) : ''))).json();
1495
+ const cur = await (await fetch('/api/current?proj=' + encodeURIComponent(Q.get('proj') || prefs.proj || '') + (S.pin ? '&id=' + encodeURIComponent(S.pin) : '') + (S.cur && !S.pin ? '&sproj=' + encodeURIComponent(S.cur.proj) + '&sid=' + encodeURIComponent(S.cur.id) : ''))).json();
1453
1496
  if (!cur.id) { $('#empty').classList.add('on'); return false; } $('#empty').classList.remove('on');
1454
1497
  if (S.pin && cur.id !== S.pin) S.pin = ''; // the pinned session is gone: follow the latest one instead of staying silently pinned
1455
1498
  if (S.cur && S.cur.id === cur.id && S.cur.proj === cur.proj) return false;
@@ -1629,15 +1672,35 @@ function setView(v) { prefs.view = v; savePrefs(); document.body.classList.toggl
1629
1672
 
1630
1673
  $('#viewSeg').onclick = (e) => { const b = e.target.closest('button'); if (b) setView(b.dataset.v); };
1631
1674
  $('#langSel').onchange = () => setLang($('#langSel').value, true);
1632
- $('#charReload').onclick = async () => { await loadChars(); for (const id in S.agents) S.agents[id].custom = customFor(id); buildStage(); paintStatus(); renderCharInfo(); };
1675
+ $('#charReload').onclick = async () => { await loadChars(); for (const k in imgCache) delete imgCache[k]; for (const id in S.agents) S.agents[id].custom = customFor(id); buildStage(); paintStatus(); renderCharInfo(); };
1633
1676
  $('#charOpen').onclick = () => window.parent.postMessage({ agentStandup: 'openCharacters' }, '*');
1634
1677
  $('#modeSeg').onclick = (e) => { const b = e.target.closest('button'); if (b) setMode(b.dataset.m); };
1635
- $('#menu').onclick = (e) => { const b = e.target.closest('.mi[data-t]'); if (!b) return; prefs[b.dataset.t] = !prefs[b.dataset.t]; savePrefs(); if (prefs[b.dataset.t] && ['voice', 'office', 'music', 'notify'].includes(b.dataset.t)) claimAudio(); b.querySelector('.sw').classList.toggle('on', prefs[b.dataset.t]); AUD.apply(); if (b.dataset.t === 'office') $('#officeBox').style.display = prefs.office ? '' : 'none'; if (b.dataset.t === 'reactions') document.body.classList.toggle('react', prefs.reactions); if (b.dataset.t === 'roster') { document.body.classList.toggle('rost', prefs.roster); paintStates(); } if (b.dataset.t === 'notify' && prefs.notify) askNotify(); if (b.dataset.t === 'vanish' && !prefs.vanish) { S.gone.clear(); paintStatus(); }
1678
+ $('#menu').onclick = (e) => { const b = e.target.closest('.mi[data-t]'); if (!b) return; prefs[b.dataset.t] = !prefs[b.dataset.t]; savePrefs(); if (prefs[b.dataset.t] && ['voice', 'office', 'music', 'notify', 'meme'].includes(b.dataset.t)) claimAudio(); b.querySelector('.sw').classList.toggle('on', prefs[b.dataset.t]); AUD.apply(); if (b.dataset.t === 'office') $('#officeBox').style.display = prefs.office ? '' : 'none'; if (b.dataset.t === 'meme') { $('#memeBox').style.display = prefs.meme ? '' : 'none'; if (prefs.meme) { claimAudio(); AUD.init(); } MEME.apply(); } if (b.dataset.t === 'reactions') document.body.classList.toggle('react', prefs.reactions); if (b.dataset.t === 'roster') { document.body.classList.toggle('rost', prefs.roster); paintStates(); } if (b.dataset.t === 'notify' && prefs.notify) askNotify(); if (b.dataset.t === 'vanish' && !prefs.vanish) { S.gone.clear(); paintStatus(); }
1636
1679
  if (b.dataset.t === 'music') { $('#musicBox').style.display = prefs.music ? '' : 'none'; if (prefs.music) { AUD.init(); AUD.apply(); } } };
1637
1680
  $('#station').innerHTML = STATIONS.map((s) => `<option value="${s.id}">📻 ${s.name}</option>`).join('');
1638
1681
  $('#station').value = prefs.station; $('#musicVol').value = prefs.musicVol; $('#musicBox').style.display = prefs.music ? '' : 'none';
1639
1682
  $('#station').onchange = (e) => { prefs.station = e.target.value; savePrefs(); AUD.init(); RADIO.sync(); };
1640
- $('#vstyle').innerHTML = [['animal', 'Animal Crossing (babillage)', 'Animal Crossing (babble)'], ['sims', 'Simlish (babillage)', 'Simlish (babble)'], ['cartoon', 'Voix cartoon (voix réelle)', 'Cartoon voices (real voice)'], ['natural', 'Voix naturelles', 'Natural voices']].map(([v, fr, en]) => `<option value="${v}" data-fr="${fr}" data-en="${en}">${L(fr, en)}</option>`).join(''); $('#vstyle').value = prefs.vstyle; $('#vstyle').onchange = () => { prefs.vstyle = $('#vstyle').value; prefs.vstyleChosen = true; savePrefs(); };
1683
+ $('#vstyle').innerHTML = [['animal', 'Animal Crossing (babillage)', 'Animal Crossing (babble)'], ['sims', 'Simlish (babillage)', 'Simlish (babble)'], ['cartoon', 'Voix cartoon (voix réelle)', 'Cartoon voices (real voice)'], ['natural', 'Voix naturelles', 'Natural voices'], ['kokoro', 'Voix très naturelles (à installer)', 'Very natural voices (installs on demand)']].map(([v, fr, en]) => `<option value="${v}" data-fr="${fr}" data-en="${en}">${L(fr, en)}</option>`).join(''); $('#vstyle').value = prefs.vstyle; $('#vstyle').onchange = () => { prefs.vstyle = $('#vstyle').value; prefs.vstyleChosen = true; savePrefs(); TTS.init().then(kokoroUI); kokoroUI(); };
1684
+ // ---- ⚙ → very natural voices (Kokoro): install box and status, polled while installing
1685
+ let kokoroTimer = null;
1686
+ async function kokoroUI() {
1687
+ const box = $('#kokoroBox'), on = prefs.vstyle === 'kokoro'; box.style.display = on ? '' : 'none'; if (!on) { clearInterval(kokoroTimer); kokoroTimer = null; return; }
1688
+ let st; try { st = await (await fetch('/api/kokoro')).json(); } catch { return; }
1689
+ const b = $('#kokoroInstall'), h = $('#kokoroHint'); b.style.display = st.installed ? 'none' : '';
1690
+ b.disabled = st.installing || !st.npm; b.textContent = st.installing ? L('Installation…', 'Installing…') : L(`Installer (${st.sizeMb} Mo)`, `Install (${st.sizeMb} MB)`);
1691
+ h.textContent = st.installing ? L('Téléchargement en cours, vous pouvez continuer : les voix du système servent en attendant.', 'Downloading, you can carry on: system voices are used meanwhile.')
1692
+ : st.error ? st.error : !st.npm && !st.installed ? L('npm est introuvable : installez Node.js pour activer cette option.', 'npm not found: install Node.js to enable this option.')
1693
+ : st.installed ? (st.gaveUp ? L('Le moteur s’est arrêté : les voix du système servent à la place.', 'The engine stopped: system voices are used instead.') : st.ready ? L('Voix prêtes.', 'Voices ready.') : L('Chargement du modèle (téléchargé au premier usage, environ 155 Mo)…', 'Loading the model (downloaded on first use, about 155 MB)…'))
1694
+ : L('Voix locales de bonne qualité, en anglais surtout (une seule voix française). Gratuit, rien n’est envoyé nulle part.', 'Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.');
1695
+ if ((st.installing || (st.installed && !st.ready && !st.gaveUp)) && !kokoroTimer) kokoroTimer = setInterval(async () => { await kokoroUI(); }, 2500);
1696
+ if (!st.installing && (st.ready || st.gaveUp || !st.installed) && kokoroTimer) { clearInterval(kokoroTimer); kokoroTimer = null; if (st.ready && TTS.engine !== 'kokoro') TTS.init(); }
1697
+ }
1698
+ $('#kokoroInstall').onclick = async () => { try { await fetch('/api/kokoro/install', { method: 'POST' }); } catch {} kokoroUI(); };
1699
+ kokoroUI();
1700
+ const showMemeLvl = () => { const l = prefs.memeLevel ?? 11; $('#memeLvl').value = l; $('#memeLvlVal').textContent = l + ' / 11' + (l === 11 ? ' 🔥' : ''); }; showMemeLvl();
1701
+ $('#memeLvl').oninput = (e) => { prefs.memeLevel = +e.target.value; savePrefs(); showMemeLvl(); };
1702
+ $('#memeVol').value = prefs.memeVol ?? 0.8; $('#memeBox').style.display = prefs.meme ? '' : 'none';
1703
+ $('#memeVol').oninput = (e) => { prefs.memeVol = +e.target.value; savePrefs(); MEME.apply(); };
1641
1704
  $('#officeVol').value = prefs.officeVol; $('#officeBox').style.display = prefs.office ? '' : 'none';
1642
1705
  $('#officeVol').oninput = (e) => { prefs.officeVol = +e.target.value; savePrefs(); AUD.applyMusic(); };
1643
1706
  $('#musicVol').oninput = (e) => { prefs.musicVol = +e.target.value; savePrefs(); AUD.applyMusic(); };
@@ -1665,13 +1728,14 @@ if (EMBED) $('#mBrowser').style.display = '';
1665
1728
 
1666
1729
  (async function init() {
1667
1730
  if (!LANGS.some((l) => l[0] === prefs.lang)) prefs.lang = 'en'; await loadLocale(prefs.lang); await loadChars();
1668
- document.body.classList.toggle('rost', prefs.roster !== false); document.body.classList.toggle('react', !!prefs.reactions); setView(prefs.view); applyLang(); applyMute(); TTS.init().then(updateHint);
1731
+ document.body.classList.toggle('rost', prefs.roster !== false); document.body.classList.toggle('react', !!prefs.reactions); setView(prefs.view); applyLang(); MEME.apply(); applyMute(); TTS.init().then(updateHint);
1669
1732
  loadHealth().then(() => { // French by default only if translation can actually work; otherwise stay in English without a warning
1670
1733
  const h = S.health || {};
1671
1734
  if (!prefs.langChosen && FR() && h.claude === null && !h.apiKey) { prefs.lang = 'en'; applyLang(); }
1672
1735
  if (FR()) fetch('/api/warm').catch(() => {});
1673
1736
  });
1674
- if (Q.get('lang')) { await setLang(Q.get('lang'), false); } // not saved: lets a link force a language
1737
+ if (Q.get('lang')) { await setLang(Q.get('lang'), false); }
1738
+ if (Q.get('meme') === '1') { prefs.meme = true; $('#memeBox').style.display = ''; MEME.apply(); } // a link can force meme mode (demo links) // not saved: lets a link force a language
1675
1739
  if (Q.get('demo')) {
1676
1740
  if (Q.get('view') === 'tree') setView('tree');
1677
1741
  await setMode('demo'); if (Q.get('speed')) $('#speed').value = Q.get('speed');
@@ -165,5 +165,16 @@
165
165
  "Open folder": "Ordner öffnen",
166
166
  "custom character(s) loaded": "eigene Figur(en) geladen",
167
167
  "Drop your pixel art (PNG) in this folder to replace the characters:": "Leg deine Pixel-Art (PNG) in diesen Ordner, um die Figuren zu ersetzen:",
168
- "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = Hauptagent · you.png = du · andere = Subagenten · name-talk.png = offener Mund"
168
+ "lead.png = the lead · you.png = you · others = the sub-agents · name-talk.png = open mouth": "lead.png = Hauptagent · you.png = du · andere = Subagenten · name-talk.png = offener Mund",
169
+ "🔥 Meme mode (unhinged)": "🔥 Meme-Modus (völlig überdreht)",
170
+ "Downloading, you can carry on: system voices are used meanwhile.": "Wird heruntergeladen, du kannst weitermachen: bis dahin kommen die Systemstimmen zum Einsatz.",
171
+ "Effects volume": "Lautstärke der Effekte",
172
+ "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Lokale Stimmen in guter Qualität, vor allem Englisch (nur eine französische Stimme). Kostenlos, nichts verlässt deinen Rechner.",
173
+ "Installing…": "Installation läuft…",
174
+ "Intensity": "Intensität",
175
+ "Loading the model (downloaded on first use, about 155 MB)…": "Modell wird geladen (beim ersten Mal heruntergeladen, etwa 155 MB)…",
176
+ "npm not found: install Node.js to enable this option.": "npm nicht gefunden: Installiere Node.js, um diese Option zu nutzen.",
177
+ "The engine stopped: system voices are used instead.": "Die Engine wurde beendet: stattdessen kommen die Systemstimmen zum Einsatz.",
178
+ "Very natural voices (installs on demand)": "Sehr natürliche Stimmen (werden bei Bedarf installiert)",
179
+ "Voices ready.": "Stimmen bereit."
169
180
  }
@@ -8,6 +8,7 @@
8
8
  "☕ This dev was replaced by his agents — buy him a coffee": "☕ This dev was replaced by his agents — buy him a coffee",
9
9
  "🎞 Export video": "🎞 Export video",
10
10
  "📋 Recap": "📋 Recap",
11
+ "🔥 Meme mode (unhinged)": "🔥 Meme mode (unhinged)",
11
12
  "Agent voices": "Agent voices",
12
13
  "Agents": "Agents",
13
14
  "Agents list (left)": "Agents list (left)",
@@ -41,10 +42,12 @@
41
42
  "done": "done",
42
43
  "Done": "Done",
43
44
  "Done. ": "Done. ",
45
+ "Downloading, you can carry on: system voices are used meanwhile.": "Downloading, you can carry on: system voices are used meanwhile.",
44
46
  "Drop your pixel art (PNG) in this folder to replace the characters:": "Drop your pixel art (PNG) in this folder to replace the characters:",
45
47
  "duration": "duration",
46
48
  "Editing ": "Editing ",
47
49
  "Editor": "Editor",
50
+ "Effects volume": "Effects volume",
48
51
  "EPISODE 1": "EPISODE 1",
49
52
  "Episode recap": "Episode recap",
50
53
  "errors": "errors",
@@ -56,10 +59,13 @@
56
59
  "Files touched": "Files touched",
57
60
  "Final report": "Final report",
58
61
  "first error": "first error",
62
+ "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.": "Good-quality local voices, mostly English (one French voice). Free, nothing leaves your machine.",
59
63
  "Highlights": "Highlights",
60
64
  "Human": "Human",
61
65
  "idle": "idle",
62
66
  "input": "input",
67
+ "Installing…": "Installing…",
68
+ "Intensity": "Intensity",
63
69
  "is sharing their screen": "is sharing their screen",
64
70
  "Join the call": "Join the call",
65
71
  "Language": "Language",
@@ -70,6 +76,7 @@
70
76
  "lines": "lines",
71
77
  "Listing ": "Listing ",
72
78
  "Little lines & animations": "Little lines & animations",
79
+ "Loading the model (downloaded on first use, about 155 MB)…": "Loading the model (downloaded on first use, about 155 MB)…",
73
80
  "longest wait ": "longest wait ",
74
81
  "Looking for a tool": "Looking for a tool",
75
82
  "looping?": "looping?",
@@ -84,6 +91,7 @@
84
91
  "No recent session": "No recent session",
85
92
  "No system voice found: agents will mumble.": "No system voice found: agents will mumble.",
86
93
  "Notify me when agents finish": "Notify me when agents finish",
94
+ "npm not found: install Node.js to enable this option.": "npm not found: install Node.js to enable this option.",
87
95
  "Office ambience (keys, coffee…)": "Office ambience (keys, coffee…)",
88
96
  "Office music (FIP radio)": "Office music (FIP radio)",
89
97
  "OFFLINE": "OFFLINE",
@@ -134,6 +142,7 @@
134
142
  "task": "task",
135
143
  "Tasks": "Tasks",
136
144
  "THE END": "THE END",
145
+ "The engine stopped: system voices are used instead.": "The engine stopped: system voices are used instead.",
137
146
  "This dev was replaced by his own agents. Spare a coffee for his career change? ☕": "This dev was replaced by his own agents. Spare a coffee for his career change? ☕",
138
147
  "Time": "Time",
139
148
  "to": "to",
@@ -149,6 +158,8 @@
149
158
  "Untitled session": "Untitled session",
150
159
  "Updating todo list": "Updating todo list",
151
160
  "Using skill ": "Using skill ",
161
+ "Very natural voices (installs on demand)": "Very natural voices (installs on demand)",
162
+ "Voices ready.": "Voices ready.",
152
163
  "VS Code was started from inside a Claude Code session: restart VS Code normally (Dock, Spotlight) to enable translation.": "VS Code was started from inside a Claude Code session: restart VS Code normally (Dock, Spotlight) to enable translation.",
153
164
  "waiting": "waiting",
154
165
  "WAITING": "WAITING",